Skip to main content

homecore_hap/
mdns.rs

1//! HAP `_hap._tcp` advertisement.
2
3use async_trait::async_trait;
4
5use crate::error::HapError;
6
7/// HAP service record advertised over mDNS.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct HapServiceRecord {
10    /// Service instance shown in discovery UI.
11    pub instance_name: String,
12    /// Bound HAP TCP port.
13    pub port: u16,
14    /// Stable colon-separated accessory device identifier.
15    pub device_id: String,
16    /// Accessory model (`md` TXT key).
17    pub model: String,
18    /// Configuration number (`c#`), incremented when accessory layout changes.
19    pub configuration_number: u32,
20    /// Current state number (`s#`).
21    pub state_number: u32,
22    /// HAP accessory category identifier. Bridges use `2`.
23    pub category: u16,
24    /// Whether a controller pairing already exists.
25    pub paired: bool,
26}
27
28impl HapServiceRecord {
29    pub fn bridge(
30        instance_name: impl Into<String>,
31        port: u16,
32        device_id: impl Into<String>,
33    ) -> Self {
34        Self {
35            instance_name: instance_name.into(),
36            port,
37            device_id: device_id.into(),
38            model: "HOMECORE Bridge".into(),
39            configuration_number: 1,
40            state_number: 1,
41            category: 2,
42            paired: false,
43        }
44    }
45
46    fn validate(&self) -> Result<(), HapError> {
47        if self.instance_name.is_empty() || self.instance_name.len() > 63 {
48            return Err(HapError::MdnsError(
49                "instance_name must contain 1..=63 bytes".into(),
50            ));
51        }
52        if self.port == 0 {
53            return Err(HapError::MdnsError("advertised port cannot be zero".into()));
54        }
55        let parts: Vec<&str> = self.device_id.split(':').collect();
56        if parts.len() != 6
57            || parts
58                .iter()
59                .any(|part| part.len() != 2 || !part.bytes().all(|byte| byte.is_ascii_hexdigit()))
60        {
61            return Err(HapError::MdnsError(
62                "device_id must be six colon-separated hexadecimal octets".into(),
63            ));
64        }
65        if self.model.is_empty() || self.model.len() > 64 {
66            return Err(HapError::MdnsError(
67                "model must contain 1..=64 bytes".into(),
68            ));
69        }
70        if self.configuration_number == 0 || self.state_number == 0 {
71            return Err(HapError::MdnsError(
72                "configuration and state numbers must be non-zero".into(),
73            ));
74        }
75        Ok(())
76    }
77
78    /// Standard HAP Bonjour TXT keys. Setup codes and controller data are
79    /// intentionally never included because TXT records are plaintext.
80    pub fn txt_records(&self) -> Vec<(String, String)> {
81        vec![
82            ("c#".into(), self.configuration_number.to_string()),
83            ("ci".into(), self.category.to_string()),
84            ("ff".into(), "0".into()),
85            ("id".into(), self.device_id.to_ascii_uppercase()),
86            ("md".into(), self.model.clone()),
87            ("pv".into(), "1.1".into()),
88            ("s#".into(), self.state_number.to_string()),
89            ("sf".into(), if self.paired { "0" } else { "1" }.into()),
90        ]
91    }
92}
93
94/// Advertise and retract a HAP service.
95#[async_trait]
96pub trait MdnsAdvertiser: Send + Sync {
97    async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError>;
98    async fn retract(&self, instance_name: &str) -> Result<(), HapError>;
99}
100
101/// Deterministic no-network advertiser for tests and disabled deployments.
102#[derive(Debug, Default, Clone)]
103pub struct NullAdvertiser;
104
105#[async_trait]
106impl MdnsAdvertiser for NullAdvertiser {
107    async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError> {
108        record.validate()?;
109        tracing::debug!(
110            instance = %record.instance_name,
111            port = record.port,
112            "HAP mDNS advertisement disabled"
113        );
114        Ok(())
115    }
116
117    async fn retract(&self, instance_name: &str) -> Result<(), HapError> {
118        tracing::debug!(instance = %instance_name, "HAP mDNS retraction disabled");
119        Ok(())
120    }
121}
122
123/// Network-backed Bonjour advertiser.
124#[cfg(feature = "hap-server")]
125pub struct MdnsSdAdvertiser {
126    daemon: mdns_sd::ServiceDaemon,
127    hostname: String,
128    address: String,
129    registrations: std::sync::Mutex<std::collections::HashMap<String, String>>,
130}
131
132#[cfg(feature = "hap-server")]
133impl std::fmt::Debug for MdnsSdAdvertiser {
134    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        formatter
136            .debug_struct("MdnsSdAdvertiser")
137            .field("hostname", &self.hostname)
138            .field("address", &self.address)
139            .finish_non_exhaustive()
140    }
141}
142
143#[cfg(feature = "hap-server")]
144impl MdnsSdAdvertiser {
145    /// Bind the mDNS daemon for a LAN-routable host/address.
146    pub fn new(hostname: impl Into<String>, address: std::net::IpAddr) -> Result<Self, HapError> {
147        let mut hostname = hostname.into();
148        if !hostname.ends_with(".local.") {
149            hostname = format!("{}.local.", hostname.trim_end_matches('.'));
150        }
151        let daemon = mdns_sd::ServiceDaemon::new()
152            .map_err(|error| HapError::MdnsError(error.to_string()))?;
153        Ok(Self {
154            daemon,
155            hostname,
156            address: address.to_string(),
157            registrations: std::sync::Mutex::new(std::collections::HashMap::new()),
158        })
159    }
160
161    fn service_info(&self, record: &HapServiceRecord) -> Result<mdns_sd::ServiceInfo, HapError> {
162        record.validate()?;
163        let properties: std::collections::HashMap<String, String> =
164            record.txt_records().into_iter().collect();
165        mdns_sd::ServiceInfo::new(
166            "_hap._tcp.local.",
167            &record.instance_name,
168            &self.hostname,
169            self.address.as_str(),
170            record.port,
171            Some(properties),
172        )
173        .map_err(|error| HapError::MdnsError(error.to_string()))
174    }
175}
176
177#[cfg(feature = "hap-server")]
178#[async_trait]
179impl MdnsAdvertiser for MdnsSdAdvertiser {
180    async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError> {
181        let info = self.service_info(record)?;
182        let fullname = info.get_fullname().to_owned();
183        self.daemon
184            .register(info)
185            .map_err(|error| HapError::MdnsError(error.to_string()))?;
186        self.registrations
187            .lock()
188            .map_err(|_| HapError::MdnsError("registration lock poisoned".into()))?
189            .insert(record.instance_name.clone(), fullname);
190        Ok(())
191    }
192
193    async fn retract(&self, instance_name: &str) -> Result<(), HapError> {
194        let fullname = self
195            .registrations
196            .lock()
197            .map_err(|_| HapError::MdnsError("registration lock poisoned".into()))?
198            .remove(instance_name);
199        if let Some(fullname) = fullname {
200            self.daemon
201                .unregister(&fullname)
202                .map_err(|error| HapError::MdnsError(error.to_string()))?;
203        }
204        Ok(())
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn txt_record_is_hap_shaped_and_contains_no_setup_secret() {
214        let record = HapServiceRecord::bridge("RuView Sense", 51826, "AA:BB:CC:DD:EE:FF");
215        let txt: std::collections::HashMap<_, _> = record.txt_records().into_iter().collect();
216        assert_eq!(txt.get("pv").map(String::as_str), Some("1.1"));
217        assert_eq!(txt.get("sf").map(String::as_str), Some("1"));
218        assert_eq!(txt.get("ci").map(String::as_str), Some("2"));
219        assert!(!txt
220            .keys()
221            .any(|key| key.contains("pin") || key.contains("code")));
222    }
223
224    #[tokio::test]
225    async fn null_advertiser_validates_without_network_io() {
226        let record = HapServiceRecord::bridge("RuView Sense", 51826, "AA:BB:CC:DD:EE:FF");
227        NullAdvertiser.advertise(&record).await.unwrap();
228        NullAdvertiser.retract(&record.instance_name).await.unwrap();
229    }
230
231    #[tokio::test]
232    async fn malformed_record_is_rejected() {
233        let record = HapServiceRecord::bridge("RuView Sense", 0, "not-an-id");
234        assert!(NullAdvertiser.advertise(&record).await.is_err());
235    }
236}