1use async_trait::async_trait;
4
5use crate::error::HapError;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct HapServiceRecord {
10 pub instance_name: String,
12 pub port: u16,
14 pub device_id: String,
16 pub model: String,
18 pub configuration_number: u32,
20 pub state_number: u32,
22 pub category: u16,
24 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 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#[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#[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#[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 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}