//! `runtime` — pure builders that turn the cog's small CLI surface //! into the shapes ADR-115's `publisher::spawn` consumes. //! //! Kept side-effect-free so the tests don't need a tokio runtime, and //! so the cog's mDNS responder / control plane (P4) can build the //! same inputs from a different source (Seed control config, JSON //! POST) without going through `clap`. //! //! Per the ADR-115 integration-test post-mortem (iter 45-48 of the //! ADR-110 sprint), the MQTT `client_id` MUST be unique per process — //! reusing a client_id causes the broker to disconnect the previous //! session and the new publisher reconnects in a loop. We derive //! `client_id` from the caller-supplied `node_id` for that reason. //! //! P3 of ADR-116: this module produces the input pair; the binary //! wires the actual `tokio::spawn(publisher::run(...))` next iter. //! //! The publisher inputs are intentionally typed in *this* crate, so //! the cog's tests and the `--print-manifest` path can exercise the //! builder without pulling in the rumqttc event loop. use std::sync::Arc; use mdns_sd::ServiceDaemon; use tokio::{sync::broadcast, task::JoinHandle}; use wifi_densepose_sensing_server::mqtt::{ config::{MqttConfig, PublishRates, TlsConfig}, publisher::{self, OwnedDiscoveryBuilder}, state::VitalsSnapshot, 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 /// without touching the environment. #[derive(Debug, Clone)] pub struct CogIdentity { /// Stable node identifier — appears in MQTT topics, HA device /// registry, mDNS service name. Must be ASCII-safe; the cog /// runtime is responsible for sanitising user input. pub node_id: String, /// Human-readable name surfaced in the HA UI. pub friendly_name: String, /// SemVer of the cog binary. Surfaces as the HA device `sw_version`. pub sw_version: String, } impl CogIdentity { /// Default identity used when the cog runs standalone (no Seed /// control plane). Uses the PID for uniqueness so two cog /// instances on the same host don't fight over the same MQTT /// session — same trick the ADR-115 publisher uses. pub fn default_for_build() -> Self { Self { node_id: format!("cog-ha-matter-{}", std::process::id()), friendly_name: "Cognitum Seed — HA cog".into(), sw_version: env!("CARGO_PKG_VERSION").into(), } } } /// The pair ADR-115's `publisher::spawn` needs. Owned so we can move /// the whole thing into a `tokio::spawn` closure without lifetime /// gymnastics. #[derive(Debug, Clone)] pub struct PublisherInputs { pub config: MqttConfig, pub discovery: OwnedDiscoveryBuilder, } /// Build the publisher inputs from the cog's small CLI surface. /// /// Pure function — no I/O, no env reads. The caller wraps `config` /// in an `Arc` before handing it to `publisher::spawn`. pub fn build_publisher_inputs( mqtt_host: &str, mqtt_port: u16, privacy_mode: bool, identity: CogIdentity, ) -> PublisherInputs { let config = MqttConfig { host: mqtt_host.to_string(), port: mqtt_port, username: None, password: None, client_id: format!("{}-{}", super::COG_ID, identity.node_id), discovery_prefix: DEFAULT_DISCOVERY_PREFIX.to_string(), tls: TlsConfig::Off, refresh_secs: 60, rates: PublishRates::default(), publish_pose: false, privacy_mode, }; let discovery = OwnedDiscoveryBuilder { discovery_prefix: DEFAULT_DISCOVERY_PREFIX.to_string(), node_id: identity.node_id, node_friendly_name: Some(identity.friendly_name), sw_version: identity.sw_version, model: format!("{MANUFACTURER} cog-ha-matter"), via_device: Some(super::COG_ID.to_string()), }; PublisherInputs { config, discovery } } /// Default broadcast-channel capacity for the cog's VitalsSnapshot /// stream. Matches the sensing-server's own default so the cog /// doesn't bottleneck the publisher under bursty loads (multi-Seed /// federation, mesh re-sync events). pub const DEFAULT_STATE_CHANNEL_CAPACITY: usize = 256; /// Spawn the ADR-115 MQTT publisher with the cog's typed inputs. /// /// Thin wrapper around [`publisher::spawn`] that: /// 1. wraps `inputs.config` in `Arc` (publisher requires shared /// ownership across reconnects), /// 2. moves `inputs.discovery` into the spawn (publisher clones it /// per reconnect; `OwnedDiscoveryBuilder` is `Clone`), /// 3. hands the broadcast receiver across without an intermediate. /// /// Returning the `JoinHandle` lets `main.rs` await it on shutdown /// (or `abort()` it from a control-plane handler). pub fn spawn_publisher( inputs: PublisherInputs, state_rx: broadcast::Receiver, ) -> JoinHandle<()> { let PublisherInputs { config, discovery } = inputs; 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::*; fn id() -> CogIdentity { CogIdentity { node_id: "seed-7".into(), friendly_name: "test-seed".into(), sw_version: "0.0.1-test".into(), } } #[test] fn host_and_port_round_trip_into_mqtt_config() { let out = build_publisher_inputs("10.0.0.5", 8883, false, id()); assert_eq!(out.config.host, "10.0.0.5"); assert_eq!(out.config.port, 8883); } #[test] fn privacy_mode_propagates_to_mqtt_config() { let on = build_publisher_inputs("h", 1883, true, id()); let off = build_publisher_inputs("h", 1883, false, id()); assert!(on.config.privacy_mode); assert!(!off.config.privacy_mode); } #[test] fn discovery_prefix_defaults_to_homeassistant() { let out = build_publisher_inputs("h", 1883, false, id()); assert_eq!(out.config.discovery_prefix, DEFAULT_DISCOVERY_PREFIX); assert_eq!(out.discovery.discovery_prefix, DEFAULT_DISCOVERY_PREFIX); } #[test] fn discovery_carries_identity_fields() { let out = build_publisher_inputs("h", 1883, false, id()); assert_eq!(out.discovery.node_id, "seed-7"); assert_eq!(out.discovery.sw_version, "0.0.1-test"); assert_eq!(out.discovery.node_friendly_name.as_deref(), Some("test-seed")); } #[test] fn via_device_advertises_cog_id() { // ADR-101 / ADR-102: every cog must surface its `id` as the // HA device's `via_device` so the appliance shows up as the // bridge — fires a named test instead of silently breaking // the device-registry shape. let out = build_publisher_inputs("h", 1883, false, id()); assert_eq!(out.discovery.via_device.as_deref(), Some(super::super::COG_ID)); } #[test] fn client_id_includes_node_id_for_session_uniqueness() { // Lesson from the ADR-115 integration-test post-mortem: two // publishers sharing a `client_id` fight over the broker // session and one reconnects forever. The cog must derive // `client_id` from `node_id` so multi-Seed deployments don't // collide. let out = build_publisher_inputs("h", 1883, false, id()); assert!(out.config.client_id.contains("seed-7")); assert!(out.config.client_id.starts_with(super::super::COG_ID)); } #[test] fn tls_defaults_to_off_for_v1_lan_only() { // v1 ships LAN-only (no broker on the open internet); TLS // wiring lands in v0.8 alongside Matter Bridge per ADR-116 // §4. Lock the default so a future refactor surfaces a // named test instead of silently enabling TLS. let out = build_publisher_inputs("h", 1883, false, id()); assert!(matches!(out.config.tls, TlsConfig::Off)); } #[tokio::test] async fn spawn_publisher_returns_live_handle_without_broker() { // No real broker on this port — rumqttc retries internally so // the spawned task stays alive. We just prove the wiring // compiles + the JoinHandle is not pre-finished. Aborting // immediately keeps the test under 100 ms. let inputs = build_publisher_inputs("127.0.0.1", 1, false, id()); let (tx, rx) = broadcast::channel::(DEFAULT_STATE_CHANNEL_CAPACITY); let handle = spawn_publisher(inputs, rx); // Task is still running (not pre-finished by config validation). assert!(!handle.is_finished()); // Keep `tx` alive past the handle abort so the receiver side // doesn't panic on drop before the task notices the channel // closed. handle.abort(); let _ = handle.await; // joined, may be Err(Cancelled) — OK. drop(tx); } #[test] fn default_state_channel_capacity_is_reasonable() { // Lock the default so a regression to e.g. 1 surfaces a named // test. Multi-Seed federation needs headroom for bursty // mesh re-sync events. 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(); assert_eq!(identity.sw_version, env!("CARGO_PKG_VERSION")); assert!(identity.node_id.starts_with("cog-ha-matter-")); // Friendly name is non-empty so HA's device card has a label. assert!(!identity.friendly_name.is_empty()); } }