//! HAP `_hap._tcp` advertisement. use async_trait::async_trait; use crate::error::HapError; /// HAP service record advertised over mDNS. #[derive(Debug, Clone, PartialEq, Eq)] pub struct HapServiceRecord { /// Service instance shown in discovery UI. pub instance_name: String, /// Bound HAP TCP port. pub port: u16, /// Stable colon-separated accessory device identifier. pub device_id: String, /// Accessory model (`md` TXT key). pub model: String, /// Configuration number (`c#`), incremented when accessory layout changes. pub configuration_number: u32, /// Current state number (`s#`). pub state_number: u32, /// HAP accessory category identifier. Bridges use `2`. pub category: u16, /// Whether a controller pairing already exists. pub paired: bool, } impl HapServiceRecord { pub fn bridge( instance_name: impl Into, port: u16, device_id: impl Into, ) -> Self { Self { instance_name: instance_name.into(), port, device_id: device_id.into(), model: "HOMECORE Bridge".into(), configuration_number: 1, state_number: 1, category: 2, paired: false, } } fn validate(&self) -> Result<(), HapError> { if self.instance_name.is_empty() || self.instance_name.len() > 63 { return Err(HapError::MdnsError( "instance_name must contain 1..=63 bytes".into(), )); } if self.port == 0 { return Err(HapError::MdnsError("advertised port cannot be zero".into())); } let parts: Vec<&str> = self.device_id.split(':').collect(); if parts.len() != 6 || parts .iter() .any(|part| part.len() != 2 || !part.bytes().all(|byte| byte.is_ascii_hexdigit())) { return Err(HapError::MdnsError( "device_id must be six colon-separated hexadecimal octets".into(), )); } if self.model.is_empty() || self.model.len() > 64 { return Err(HapError::MdnsError( "model must contain 1..=64 bytes".into(), )); } if self.configuration_number == 0 || self.state_number == 0 { return Err(HapError::MdnsError( "configuration and state numbers must be non-zero".into(), )); } Ok(()) } /// Standard HAP Bonjour TXT keys. Setup codes and controller data are /// intentionally never included because TXT records are plaintext. pub fn txt_records(&self) -> Vec<(String, String)> { vec![ ("c#".into(), self.configuration_number.to_string()), ("ci".into(), self.category.to_string()), ("ff".into(), "0".into()), ("id".into(), self.device_id.to_ascii_uppercase()), ("md".into(), self.model.clone()), ("pv".into(), "1.1".into()), ("s#".into(), self.state_number.to_string()), ("sf".into(), if self.paired { "0" } else { "1" }.into()), ] } } /// Advertise and retract a HAP service. #[async_trait] pub trait MdnsAdvertiser: Send + Sync { async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError>; async fn retract(&self, instance_name: &str) -> Result<(), HapError>; } /// Deterministic no-network advertiser for tests and disabled deployments. #[derive(Debug, Default, Clone)] pub struct NullAdvertiser; #[async_trait] impl MdnsAdvertiser for NullAdvertiser { async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError> { record.validate()?; tracing::debug!( instance = %record.instance_name, port = record.port, "HAP mDNS advertisement disabled" ); Ok(()) } async fn retract(&self, instance_name: &str) -> Result<(), HapError> { tracing::debug!(instance = %instance_name, "HAP mDNS retraction disabled"); Ok(()) } } /// Network-backed Bonjour advertiser. #[cfg(feature = "hap-server")] pub struct MdnsSdAdvertiser { daemon: mdns_sd::ServiceDaemon, hostname: String, address: String, registrations: std::sync::Mutex>, } #[cfg(feature = "hap-server")] impl std::fmt::Debug for MdnsSdAdvertiser { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("MdnsSdAdvertiser") .field("hostname", &self.hostname) .field("address", &self.address) .finish_non_exhaustive() } } #[cfg(feature = "hap-server")] impl MdnsSdAdvertiser { /// Bind the mDNS daemon for a LAN-routable host/address. pub fn new(hostname: impl Into, address: std::net::IpAddr) -> Result { let mut hostname = hostname.into(); if !hostname.ends_with(".local.") { hostname = format!("{}.local.", hostname.trim_end_matches('.')); } let daemon = mdns_sd::ServiceDaemon::new() .map_err(|error| HapError::MdnsError(error.to_string()))?; Ok(Self { daemon, hostname, address: address.to_string(), registrations: std::sync::Mutex::new(std::collections::HashMap::new()), }) } fn service_info(&self, record: &HapServiceRecord) -> Result { record.validate()?; let properties: std::collections::HashMap = record.txt_records().into_iter().collect(); mdns_sd::ServiceInfo::new( "_hap._tcp.local.", &record.instance_name, &self.hostname, self.address.as_str(), record.port, Some(properties), ) .map_err(|error| HapError::MdnsError(error.to_string())) } } #[cfg(feature = "hap-server")] #[async_trait] impl MdnsAdvertiser for MdnsSdAdvertiser { async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError> { let info = self.service_info(record)?; let fullname = info.get_fullname().to_owned(); self.daemon .register(info) .map_err(|error| HapError::MdnsError(error.to_string()))?; self.registrations .lock() .map_err(|_| HapError::MdnsError("registration lock poisoned".into()))? .insert(record.instance_name.clone(), fullname); Ok(()) } async fn retract(&self, instance_name: &str) -> Result<(), HapError> { let fullname = self .registrations .lock() .map_err(|_| HapError::MdnsError("registration lock poisoned".into()))? .remove(instance_name); if let Some(fullname) = fullname { self.daemon .unregister(&fullname) .map_err(|error| HapError::MdnsError(error.to_string()))?; } Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn txt_record_is_hap_shaped_and_contains_no_setup_secret() { let record = HapServiceRecord::bridge("RuView Sense", 51826, "AA:BB:CC:DD:EE:FF"); let txt: std::collections::HashMap<_, _> = record.txt_records().into_iter().collect(); assert_eq!(txt.get("pv").map(String::as_str), Some("1.1")); assert_eq!(txt.get("sf").map(String::as_str), Some("1")); assert_eq!(txt.get("ci").map(String::as_str), Some("2")); assert!(!txt .keys() .any(|key| key.contains("pin") || key.contains("code"))); } #[tokio::test] async fn null_advertiser_validates_without_network_io() { let record = HapServiceRecord::bridge("RuView Sense", 51826, "AA:BB:CC:DD:EE:FF"); NullAdvertiser.advertise(&record).await.unwrap(); NullAdvertiser.retract(&record.instance_name).await.unwrap(); } #[tokio::test] async fn malformed_record_is_rejected() { let record = HapServiceRecord::bridge("RuView Sense", 0, "not-an-id"); assert!(NullAdvertiser.advertise(&record).await.is_err()); } }