use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
use tracing::{debug, info};
use crate::homeauto::matter::crypto::kdf::hkdf_expand_label;
use crate::homeauto::matter::error::{MatterError, MatterResult};
use crate::homeauto::matter::fabric::types::FabricDescriptor;
pub fn derive_compressed_fabric_id(fabric: &FabricDescriptor) -> u64 {
let salt = fabric.fabric_id.to_be_bytes();
let raw = hkdf_expand_label(&fabric.root_public_key, &salt, "CompressedFabric", 8);
u64::from_be_bytes(raw[..8].try_into().expect("HKDF produced 8 bytes"))
}
pub struct OperationalAdvertiser {
daemon: ServiceDaemon,
service_fullname: String,
}
impl OperationalAdvertiser {
pub fn start(fabric: &FabricDescriptor, port: u16) -> MatterResult<Self> {
let daemon =
ServiceDaemon::new().map_err(|e| MatterError::Mdns(format!("daemon init: {e}")))?;
let cfid = derive_compressed_fabric_id(fabric);
let instance_name = format!("{:016X}-{:016X}", cfid, fabric.node_id);
const SERVICE_TYPE: &str = "_matter._tcp";
let txt_owned: Vec<(&str, String)> = vec![
("SII", "5000".to_string()),
("SAI", "300".to_string()),
("T", "0".to_string()),
];
let txt_refs: Vec<(&str, &str)> = txt_owned.iter().map(|(k, v)| (*k, v.as_str())).collect();
let hostname = gethostname::gethostname().to_string_lossy().to_string();
let host_fqdn = if hostname.is_empty() {
format!("matter-node-{:016X}.local.", fabric.node_id)
} else {
format!("{hostname}.local.")
};
let svc = ServiceInfo::new(
SERVICE_TYPE,
&instance_name,
&host_fqdn,
(),
port,
txt_refs.as_slice(),
)
.map_err(|e| MatterError::Mdns(format!("ServiceInfo: {e}")))?;
let service_fullname = svc.get_fullname().to_string();
daemon
.register(svc)
.map_err(|e| MatterError::Mdns(format!("register: {e}")))?;
info!(
"Matter mDNS: operational '{}' on port {}",
instance_name, port
);
Ok(Self {
daemon,
service_fullname,
})
}
pub fn stop(&self) -> MatterResult<()> {
self.daemon
.unregister(&self.service_fullname)
.map_err(|e| MatterError::Mdns(format!("unregister: {e}")))?;
Ok(())
}
pub fn service_fullname(&self) -> &str {
&self.service_fullname
}
}
impl Drop for OperationalAdvertiser {
fn drop(&mut self) {
let _ = self.stop();
}
}
pub struct OperationalBrowser {
daemon: ServiceDaemon,
}
impl OperationalBrowser {
pub fn new() -> MatterResult<Self> {
let daemon =
ServiceDaemon::new().map_err(|e| MatterError::Mdns(format!("daemon init: {e}")))?;
Ok(Self { daemon })
}
pub async fn discover_node(
&self,
compressed_fabric_id: u64,
node_id: u64,
timeout_ms: u64,
) -> MatterResult<SocketAddr> {
const SERVICE_TYPE: &str = "_matter._tcp";
let target_instance = format!("{:016X}-{:016X}", compressed_fabric_id, node_id);
let target_fullname = format!("{target_instance}.{SERVICE_TYPE}.local.");
debug!(
"Matter browse: looking for operational node '{}'",
target_fullname
);
let receiver = self
.daemon
.browse(SERVICE_TYPE)
.map_err(|e| MatterError::Mdns(format!("browse: {e}")))?;
let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
loop {
let remaining = deadline
.checked_duration_since(std::time::Instant::now())
.unwrap_or(Duration::ZERO);
if remaining.is_zero() {
break;
}
match receiver.recv_timeout(remaining) {
Ok(ServiceEvent::ServiceResolved(info)) => {
if Self::fullname_matches(&info, &target_fullname) {
let addr = Self::pick_addr(&info, &target_fullname)?;
let _ = self.daemon.stop_browse(SERVICE_TYPE);
return Ok(addr);
}
}
Ok(_) => {} Err(_) => break, }
}
let _ = self.daemon.stop_browse(SERVICE_TYPE);
Err(MatterError::Transport(format!(
"node '{target_fullname}' not found within {timeout_ms}ms"
)))
}
fn fullname_matches(info: &ServiceInfo, target: &str) -> bool {
info.get_fullname().eq_ignore_ascii_case(target)
}
fn pick_addr(info: &ServiceInfo, fullname: &str) -> MatterResult<SocketAddr> {
let port = info.get_port();
let addr = info
.get_addresses()
.iter()
.find(|a| matches!(a, IpAddr::V6(_)))
.or_else(|| info.get_addresses().iter().next())
.copied()
.ok_or_else(|| MatterError::Transport(format!("no address for '{fullname}'")))?;
Ok(SocketAddr::new(addr, port))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::homeauto::matter::fabric::types::{FabricDescriptor, FabricIndex};
fn test_fabric(fabric_id: u64, node_id: u64) -> FabricDescriptor {
let mut root_public_key = vec![0u8; 65];
root_public_key[0] = 0x04; FabricDescriptor {
fabric_index: FabricIndex(1),
root_public_key,
vendor_id: 0xFFF1,
fabric_id,
node_id,
label: "test".to_string(),
}
}
#[test]
fn compressed_fabric_id_derivation() {
let fabric1 = test_fabric(1, 0);
let fabric2 = test_fabric(2, 0);
let cfid1a = derive_compressed_fabric_id(&fabric1);
let cfid1b = derive_compressed_fabric_id(&fabric1);
let cfid2 = derive_compressed_fabric_id(&fabric2);
assert_eq!(cfid1a, cfid1b, "CompressedFabricId must be deterministic");
assert_ne!(
cfid1a, cfid2,
"Different fabric IDs must produce different compressed IDs"
);
let expected = {
use hkdf::Hkdf;
use sha2::Sha256;
let fabric = test_fabric(1, 0);
let salt = fabric.fabric_id.to_be_bytes();
let hk = Hkdf::<Sha256>::new(Some(&salt), &fabric.root_public_key);
let mut out = [0u8; 8];
hk.expand(b"CompressedFabric", &mut out).unwrap();
u64::from_be_bytes(out)
};
assert_eq!(cfid1a, expected, "CompressedFabricId derivation mismatch");
}
#[test]
fn operational_instance_name_format() {
let fabric = test_fabric(1, 2);
let cfid = derive_compressed_fabric_id(&fabric);
let instance = format!("{:016X}-{:016X}", cfid, fabric.node_id);
assert_eq!(
instance.len(),
33,
"Instance name must be 33 chars long, got: '{instance}'"
);
let parts: Vec<&str> = instance.splitn(2, '-').collect();
assert_eq!(parts.len(), 2, "Instance name must contain exactly one '-'");
assert_eq!(parts[0].len(), 16, "CFID hex part must be 16 chars");
assert_eq!(parts[1].len(), 16, "NodeID hex part must be 16 chars");
assert!(
parts[0]
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_lowercase()),
"CFID must be uppercase hex"
);
assert!(
parts[1]
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_lowercase()),
"NodeID must be uppercase hex"
);
assert_eq!(parts[1], "0000000000000002");
}
}