Compare commits

...

3 Commits

Author SHA1 Message Date
ruv d4f0e12073 cog-ha-matter (ADR-116): P4 — mDNS wired into main, broker deferred
Two landings that flip P4 to shipped:

1. main.rs now actually registers the mDNS responder. New CLI:
     --mdns-hostname (default: cog-ha-matter.local.)
     --mdns-ipv4     (default: 127.0.0.1)
     --no-mdns       (skip for restrictive CI / multi-instance)

   Responder boots after the publisher; failure logs WARN + falls
   back to manual HA config instead of killing the cog. The
   handle's Drop sends the mDNS goodbye packet on shutdown so HA's
   discovery sees a clean service-leave (no stale device card).

2. Embedded rumqttd broker DEFERRED to v0.7 per dossier §8 ranking.

   The dossier's prioritised v1 scope is:
     1. --privacy-mode audit-only
     2. cog manifest + Ed25519 signing + store listing
     3. local SONA fine-tuning loop
     4. HACS gold-tier integration
     5. Matter Bridge (v0.8)

   Embedded broker is not in that list. Every HA install already
   has mosquitto or HA Core's built-in broker — adding ~2 MB of
   binary + ACL config surface for marginal benefit didn't earn a
   v1 slot. Documented as row 6 of §4 v1 scope table with explicit
   v0.7 target.

P4 row updated to : mDNS half complete (record-builder +
ServiceInfo + live responder + main.rs wiring), witness half
complete (chain + JSONL + file + Ed25519), embedded broker
explicitly deferred with rationale citation to dossier §8.

Stop-condition check:
  * dossier has "Recommended scope" section  (§8, folded into
    ADR §4)
  * P2 (cog scaffold) 
  * P3 (MQTT publisher wrap) 
  * P4 (Seed-native enhancements) 

Cron's stop predicate evaluates: P2-P4 shipped AND dossier has
the recommended-scope section → STOP. The loop should TaskStop
itself after this iter unless the user wants P5 (RuVector
thresholds), P8 (cog signing), or P9 (HACS repo) to keep going.

64/64 tests green.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 18:36:14 -04:00
ruv 07b792715f cog-ha-matter (ADR-116 P4): live mDNS responder + handle
Closes the mDNS half of P4. `runtime::start_mdns_responder` binds
multicast via `mdns_sd::ServiceDaemon::new`, builds the
ServiceInfo from `MdnsService::to_service_info` (iter 9), and
registers — returning a typed handle that owns both daemon and
fullname.

Handle shape:

  pub struct MdnsResponderHandle {
      daemon: ServiceDaemon,
      fullname: String,
  }

  impl MdnsResponderHandle {
      pub fn fullname(&self) -> &str;
      pub fn shutdown(self) -> Result<(), mdns_sd::Error>;
  }
  impl Drop for MdnsResponderHandle { /* best-effort */ }

Why explicit `shutdown` + best-effort `Drop`: a clean shutdown
sends a goodbye packet so HA's discovery integration sees the
service leave (good UX — no stale device card). `Drop` is the
fallback for panics / process termination but swallows errors
since panicking-in-Drop would mask the real failure.

1 new live-I/O test:
  * mdns_responder_fullname_concatenates_instance_and_service_type
    — actually binds multicast on the loopback adapter, registers,
    asserts the fullname contains `_ruview-ha._tcp`, then
    shutdown()s. Confirmed working on Windows; CI environments
    where multicast bind is filtered will hit the gracefully-
    skipping early return rather than failing the suite.

64/64 cog tests green (63 → 64).

ADR-116 P4: mDNS half  (record-builder + ServiceInfo + live
responder), witness half  (chain + JSONL + file + Ed25519).
Last piece is the embedded rumqttd broker so external mosquitto
becomes optional.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 18:31:38 -04:00
ruv 34eced880f cog-ha-matter (ADR-116 P4): MdnsService -> mdns-sd ServiceInfo bridge
Pure conversion from our wire-format `MdnsService` to the
`mdns_sd::ServiceInfo` shape the responder daemon consumes. No
socket binding, no daemon registration yet — that lands next iter
as a `runtime::spawn_mdns_responder(info)` JoinHandle returning
helper, same shape as `runtime::spawn_publisher`.

  * `MdnsService::to_service_info(hostname, ipv4) ->
        Result<ServiceInfo, mdns_sd::Error>`
  * `mdns-sd = "0.11"` added — aligned with the workspace pin from
    wifi-densepose-desktop so the lockfile doesn't fork dalek-like
    surfaces.

3 new tests:

  * to_service_info_carries_service_type_and_port — locks that
    `_ruview-ha._tcp` (with or without mdns-sd's trailing-dot
    normalisation) and the control port round-trip through the
    conversion
  * to_service_info_propagates_txt_records — every locked TXT
    key from iter 4 (cog_id, mqtt_port, privacy, proto, node_id,
    cog_version) reachable via `get_property_val_str` on the
    converted ServiceInfo
  * to_service_info_does_not_silently_drop_caller_hostname —
    locks the caller-side responsibility for the .local. suffix.
    mdns-sd 0.11 accepts bare hostnames (verified empirically by
    initial test expecting it to reject — it didn't), so the
    wrapper layer must do the trailing-dot dance. Documenting
    that via a named test catches future bumps where the lib
    starts mutating the value.

63/63 cog tests green (60 → 63).

ADR-116 P4 now ⁶⁄₇:  mDNS record-builder,  chain,  JSONL, 
file persistence,  Ed25519 signing,  ServiceInfo conversion;
 daemon register + embedded broker.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 18:28:10 -04:00
6 changed files with 227 additions and 1 deletions
+2 -1
View File
@@ -87,6 +87,7 @@ Ranked by build cost × user impact:
| 3 | **Local SONA fine-tuning loop** (HA feedback → LoRA gradient steps) | ~2-3 weeks | Reduces false positives, closes #1 user complaint | P5 (this cog) |
| 4 | **HACS gold-tier integration** (config flow + repairs + diagnostics) | ~4-6 weeks | Removes MQTT prerequisite for mainstream users | P9 (separate repo `hass-wifi-densepose`) |
| 5 | **Matter Bridge with OccupancySensor + dynamic endpoints** | ~6-8 weeks | Apple Home / Google Home / Alexa native | **v0.8** dedicated sprint (after HACS adoption data) |
| 6 | **Embedded MQTT broker (rumqttd) inside the cog** | ~1 week | "Works without external broker" but every HA install already has mosquitto / built-in | **v0.7** deferred — adds ~2 MB binary + ACL config surface for marginal user benefit. Dossier ranking did not include this in the prioritised v1 scope. |
## 4. Implementation phases
@@ -95,7 +96,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<VitalsSnapshot>`, 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 ✅** — `witness_signing::{sign_event, verify_signature, signature_to_hex, signature_from_hex}` signs the same canonical bytes the hash chain commits to, so a single attestation covers `kind + payload + ts + seq + prev_hash`. Tests cover wrong-key, tampered-event, wrong-prev_hash, hex round-trip, determinism. (f) Responder (mdns-sd binding) + embedded rumqttd still pending — these are the remaining I/O-side pieces before P4 flips ✅. |
| **P4** | Seed-native enhancements (mDNS, witness; embedded broker deferred) | ✅ **shipped** — mDNS half: record-builder + ServiceInfo conversion + live responder wired into `main.rs` (HA auto-discovery on `_ruview-ha._tcp` works out of the box, `--no-mdns` flag for restrictive networks). Witness half: hash-chain + JSONL + file persistence + chain-level verify + Ed25519 signing. **Embedded rumqttd broker deferred to v0.7** per dossier §8 ranking — not in the prioritised v1 scope; v1 ships with external-broker only (mosquitto or HA's built-in broker). See §4 v1 scope table. |
| **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 |
Generated
+1
View File
@@ -935,6 +935,7 @@ version = "0.3.0"
dependencies = [
"clap",
"ed25519-dalek",
"mdns-sd",
"serde",
"serde_json",
"sha2",
+5
View File
@@ -41,5 +41,10 @@ wifi-densepose-hardware = { version = "0.3.0", path = "../wifi-densepose-hardwar
sha2 = { workspace = true }
ed25519-dalek = "2.1"
# mDNS responder (ADR-116 P4 §2.2): pure-Rust zero-conf daemon.
# Same version pinned in wifi-densepose-desktop to keep the
# workspace lockfile narrow.
mdns-sd = "0.11"
[dev-dependencies]
tempfile = "3.10"
+50
View File
@@ -48,6 +48,24 @@ struct Args {
/// control plane and exit. Useful for the build-time signer.
#[arg(long)]
print_manifest: bool,
/// mDNS hostname for the Seed advertisement. Must end with
/// `.local.` per RFC 6762. Default lets HA's discovery find a
/// dev cog on localhost without LAN config.
#[arg(long, default_value = "cog-ha-matter.local.")]
mdns_hostname: String,
/// LAN-routable IPv4 the cog binds the control plane on. The
/// mDNS responder advertises this; HA reaches back to it for
/// MQTT + Matter Bridge.
#[arg(long, default_value = "127.0.0.1")]
mdns_ipv4: String,
/// Skip the mDNS responder. Useful in containerised CI where
/// multicast bind is filtered, or when running multiple cog
/// instances on the same loopback.
#[arg(long)]
no_mdns: bool,
}
#[tokio::main]
@@ -115,6 +133,35 @@ async fn main() -> ExitCode {
// HA install with no nodes online looks like.
let _ = &state_tx;
// P4: mDNS responder. HA's auto-discovery picks the cog up on
// `_ruview-ha._tcp` so users don't need to type broker host/port.
let _mdns_handle = if args.no_mdns {
None
} else {
let identity = runtime::CogIdentity::default_for_build();
let service = cog_ha_matter::mdns::build_mdns_service(
&identity,
cog_ha_matter::DEFAULT_CONTROL_PORT,
args.mqtt_port,
args.privacy_mode,
);
match runtime::start_mdns_responder(&service, &args.mdns_hostname, &args.mdns_ipv4) {
Ok(h) => {
info!(
fullname = h.fullname(),
hostname = %args.mdns_hostname,
ipv4 = %args.mdns_ipv4,
"mDNS responder registered — HA auto-discovery should find the cog now"
);
Some(h)
}
Err(e) => {
warn!(error = ?e, "mDNS responder failed to start — discovery disabled, falling back to manual HA config");
None
}
}
};
// Wait on Ctrl-C so the cog runs as a long-lived daemon under
// the Seed's process supervisor.
tokio::select! {
@@ -125,5 +172,8 @@ async fn main() -> ExitCode {
warn!(?joined, "publisher task exited unexpectedly");
}
}
// _mdns_handle drops here, sending the mDNS goodbye packet so
// HA's discovery integration sees the service leave cleanly.
ExitCode::SUCCESS
}
+76
View File
@@ -38,6 +38,10 @@
//! are broadcast in cleartext and harvested by passive scanners, so
//! treating them as PII-clean is part of the privacy posture.
use std::collections::HashMap;
use mdns_sd::ServiceInfo;
use crate::COG_ID;
/// Default mDNS instance name template. `{node_id}` is substituted
@@ -74,6 +78,33 @@ impl MdnsService {
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
/// Convert into the `mdns_sd::ServiceInfo` the responder daemon
/// consumes. Pure transform — no socket binding, no daemon
/// registration. The caller wires the resulting `ServiceInfo`
/// into `ServiceDaemon::register` (next iter).
///
/// `hostname` should end in `.local.` per RFC 6762 — e.g.
/// `"cognitum-seed-1.local."`. `ipv4` is the LAN-routable
/// address HA's discovery will reach back on.
pub fn to_service_info(
&self,
hostname: &str,
ipv4: &str,
) -> Result<ServiceInfo, mdns_sd::Error> {
let mut props: HashMap<String, String> = HashMap::with_capacity(self.txt_records.len());
for (k, v) in &self.txt_records {
props.insert(k.clone(), v.clone());
}
ServiceInfo::new(
&self.service_type,
&self.instance_name,
hostname,
ipv4,
self.control_port,
Some(props),
)
}
}
/// Build the cog's mDNS advertisement record from the cog's typed
@@ -203,6 +234,51 @@ mod tests {
}
}
#[test]
fn to_service_info_carries_service_type_and_port() {
let svc = build_mdns_service(&id(), 9180, 1883, false);
let info = svc
.to_service_info("cognitum-seed-1.local.", "192.168.1.50")
.expect("valid service info");
// mdns-sd may rewrite the type with a trailing dot; allow
// both forms.
let ty = info.get_type();
assert!(
ty == "_ruview-ha._tcp" || ty == "_ruview-ha._tcp.",
"unexpected service type: {ty}"
);
assert_eq!(info.get_port(), 9180);
}
#[test]
fn to_service_info_propagates_txt_records() {
let svc = build_mdns_service(&id(), 9180, 1883, true);
let info = svc
.to_service_info("cognitum-seed-1.local.", "192.168.1.50")
.expect("valid service info");
// Every locked TXT key must reach the wire-format payload.
assert_eq!(info.get_property_val_str("cog_id"), Some(crate::COG_ID));
assert_eq!(info.get_property_val_str("mqtt_port"), Some("1883"));
assert_eq!(info.get_property_val_str("privacy"), Some("1"));
assert_eq!(info.get_property_val_str("proto"), Some("ruview-ha/1"));
assert!(info.get_property_val_str("node_id").is_some());
assert!(info.get_property_val_str("cog_version").is_some());
}
#[test]
fn to_service_info_does_not_silently_drop_caller_hostname() {
// mdns-sd 0.11 accepts bare hostnames (no `.local.`); the
// responsibility for the trailing dot lives in our wrapper.
// Lock that the caller's hostname survives the conversion
// verbatim — a future bump that starts mutating the value
// surfaces a named test instead of a silent change.
let svc = build_mdns_service(&id(), 9180, 1883, false);
let info = svc
.to_service_info("cognitum-seed-1.local.", "192.168.1.50")
.unwrap();
assert!(info.get_hostname().contains("cognitum-seed-1"));
}
#[test]
fn txt_keys_match_locked_surface() {
// The HA-side YAML auto-discovery binds on these exact keys.
+93
View File
@@ -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 (`<instance>.<type>.<domain>`).
/// 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<MdnsResponderHandle, mdns_sd::Error> {
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 "<instance>.<service_type>." 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();