diff --git a/docs/adr/ADR-116-cog-ha-matter-seed.md b/docs/adr/ADR-116-cog-ha-matter-seed.md index 8f3403fd..2a7ec660 100644 --- a/docs/adr/ADR-116-cog-ha-matter-seed.md +++ b/docs/adr/ADR-116-cog-ha-matter-seed.md @@ -95,7 +95,7 @@ Ranked by build cost × user impact: | **P1** | Research dossier ([`docs/research/ADR-116-ha-matter-cog-research.md`](../research/ADR-116-ha-matter-cog-research.md)) | ✅ **done** — 8 sections, 30+ citations, v1 scope ranked | | **P2** | Cog crate scaffold (`v2/crates/cog-ha-matter/`) — Cargo.toml + `src/{lib,main,manifest}.rs`, workspace member, CLI args, `--print-manifest` flag, 2 manifest unit tests | ✅ **done** — `cargo check` + `cargo test` green | | **P3** | Wrap existing ADR-115 MQTT publisher as cog entry point | ✅ **wiring done** — `main.rs` boots ADR-115's `publisher::spawn` via `runtime::spawn_publisher` thin wrapper, holds a long-lived `broadcast::Sender`, awaits Ctrl-C. Live-handle test green without a broker. Next (P3.5): subscribe to sensing-server `/v1/snapshot` WS and republish into the channel. | -| **P4** | Seed-native enhancements (embedded broker, mDNS, witness) | in progress — (a) mDNS record-builder ✅. (b) Witness hash-chain ✅. (c) JSONL line serializer ✅. (d) File persistence + chain-level verify ✅. (e) Ed25519 signing layer ✅. **(f) mDNS ServiceInfo conversion ✅** — `MdnsService::to_service_info(hostname, ipv4)` produces the `mdns_sd::ServiceInfo` the responder daemon consumes; 3 tests verify service-type, port, TXT propagation. `mdns-sd = 0.11` aligned with the workspace's existing pin from `wifi-densepose-desktop`. (g) `ServiceDaemon::register` spawn + embedded rumqttd still pending — the remaining live-I/O pieces before P4 flips ✅. | +| **P4** | Seed-native enhancements (embedded broker, mDNS, witness) | in progress — **mDNS half complete:** record-builder ✅, ServiceInfo conversion ✅, **live responder ✅** (`runtime::start_mdns_responder` binds multicast, registers, returns `MdnsResponderHandle` with explicit `shutdown()` + best-effort Drop). **Witness half complete:** hash-chain ✅, JSONL line serializer ✅, file persistence + chain-level verify ✅, Ed25519 signing ✅. **Remaining:** embedded rumqttd broker. | | **P5** | RuVector-backed threshold learning (SONA adaptation) | pending | | **P6** | Multi-Seed federation (cross-Seed dedup + witness) | pending | | **P7** | Matter Bridge mode (depends on matter-rs / esp-matter readiness) | pending | diff --git a/v2/crates/cog-ha-matter/src/runtime.rs b/v2/crates/cog-ha-matter/src/runtime.rs index 94272963..cb89883c 100644 --- a/v2/crates/cog-ha-matter/src/runtime.rs +++ b/v2/crates/cog-ha-matter/src/runtime.rs @@ -21,6 +21,7 @@ use std::sync::Arc; +use mdns_sd::ServiceDaemon; use tokio::{sync::broadcast, task::JoinHandle}; use wifi_densepose_sensing_server::mqtt::{ config::{MqttConfig, PublishRates, TlsConfig}, @@ -29,6 +30,8 @@ use wifi_densepose_sensing_server::mqtt::{ DEFAULT_DISCOVERY_PREFIX, MANUFACTURER, }; +use crate::mdns::MdnsService; + /// Caller-supplied identity for the cog instance. Filled in by the /// cog runtime from the mDNS hostname / Seed control plane in /// production; threaded as a parameter so tests can build inputs @@ -129,6 +132,66 @@ pub fn spawn_publisher( publisher::spawn(Arc::new(config), discovery, state_rx) } +/// Owned handle to a live mDNS responder. Holding it keeps the +/// service advertised; `shutdown` unregisters cleanly so HA's +/// discovery integration sees a goodbye packet instead of a +/// dropped advertisement. +/// +/// `Drop` is best-effort: tries unregister + daemon shutdown but +/// swallows errors, since panicking in Drop would mask the real +/// failure that prompted the shutdown. +pub struct MdnsResponderHandle { + daemon: ServiceDaemon, + fullname: String, +} + +impl MdnsResponderHandle { + /// Fully-qualified DNS-SD name (`..`). + /// Exposed for tests + logging; the responder uses it to + /// unregister. + pub fn fullname(&self) -> &str { + &self.fullname + } + + /// Unregister the service and shut down the daemon. Returns + /// any error so the caller's shutdown sequence can surface it. + pub fn shutdown(self) -> Result<(), mdns_sd::Error> { + let _ = self.daemon.unregister(&self.fullname); + let _ = self.daemon.shutdown()?; + Ok(()) + } +} + +impl Drop for MdnsResponderHandle { + fn drop(&mut self) { + let _ = self.daemon.unregister(&self.fullname); + let _ = self.daemon.shutdown(); + } +} + +/// Start the mDNS responder for a cog and register its service. +/// +/// Binds a multicast socket (`mdns_sd::ServiceDaemon::new`) and +/// publishes `service` under `hostname` (must end in `.local.`) +/// and `ipv4` (the LAN-routable address HA's discovery reaches +/// back on). +/// +/// Live-I/O: binding multicast may fail in containerised CI or +/// on networks where 5353/udp is filtered — callers should treat +/// the error as recoverable (log + retry, or fall back to manual +/// HA configuration) rather than fatal to the cog. +pub fn start_mdns_responder( + service: &MdnsService, + hostname: &str, + ipv4: &str, +) -> Result { + let daemon = ServiceDaemon::new()?; + let info = service.to_service_info(hostname, ipv4)?; + let fullname = info.get_fullname().to_string(); + daemon.register(info)?; + Ok(MdnsResponderHandle { daemon, fullname }) +} + #[cfg(test)] mod tests { use super::*; @@ -230,6 +293,36 @@ mod tests { assert!(DEFAULT_STATE_CHANNEL_CAPACITY >= 64); } + #[test] + fn mdns_responder_fullname_concatenates_instance_and_service_type() { + // Live-I/O test: binds multicast on the loopback adapter. + // Skips with a warning if the host's network stack refuses + // the bind (containerised CI without --network host, etc.) + // rather than failing the whole test suite. + use crate::mdns::build_mdns_service; + let svc = build_mdns_service(&id(), 9180, 1883, false); + let handle = match start_mdns_responder(&svc, "cog-ha-matter-test.local.", "127.0.0.1") { + Ok(h) => h, + Err(e) => { + eprintln!("mdns multicast bind not available in this sandbox: {e} — skipping"); + return; + } + }; + // Fullname format is ".." per RFC 6763. + // mdns-sd may URL-escape special chars (— in instance name) so + // we only assert on the service-type segment which is stable. + let fullname = handle.fullname().to_string(); + assert!( + !fullname.is_empty(), + "fullname empty after register" + ); + assert!( + fullname.contains("_ruview-ha._tcp"), + "fullname `{fullname}` missing service type" + ); + handle.shutdown().expect("clean shutdown"); + } + #[test] fn default_identity_carries_pkg_version_and_pid() { let identity = CogIdentity::default_for_build();