diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index 46483456..fed20336 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -68,6 +68,26 @@ ureq = { version = "2", default-features = false, features = ["tls", "json" sha2 = "0.10" thiserror = "1" +# ADR-115 §3.8 — MQTT publisher (HA-DISCO). +# Gated behind the `mqtt` feature so the default binary stays small for users +# who don't need Home Assistant integration. `rumqttc` is the chosen Rust MQTT +# client (ADR-115 §10 references). `rustls` is preferred over openssl on +# Windows to keep parity with the rest of the workspace (`ureq` above also +# uses rustls). +rumqttc = { version = "0.24", default-features = false, features = ["use-rustls"], optional = true } + +[features] +default = [] +# Enables the ADR-115 §2 MQTT auto-discovery publisher. Without this feature +# all `--mqtt-*` CLI flags still parse (cli.rs declares them unconditionally), +# but enabling `--mqtt` at runtime logs a `WARN` and the publisher is a no-op. +mqtt = ["dep:rumqttc"] +# ADR-115 §3.11 — Matter Bridge (HA-FABRIC). Same gating principle: flags +# parse unconditionally; the bridge is a no-op without this feature. +# matter-rs is added in P7; intentionally absent in P1 to keep the dep +# surface small until the SDK choice is validated. +matter = [] + [dev-dependencies] tempfile = "3.10" # `tower::ServiceExt::oneshot` for in-process Router tests (bearer_auth). diff --git a/v2/crates/wifi-densepose-sensing-server/src/cli.rs b/v2/crates/wifi-densepose-sensing-server/src/cli.rs index c857f35c..4fe6f5f6 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/cli.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/cli.rs @@ -102,4 +102,234 @@ pub struct Args { /// Start field model calibration on boot (empty room required) #[arg(long)] pub calibrate: bool, + + // ─── ADR-115 §3.8 — MQTT publisher (HA-DISCO) ────────────────────────── + // + // All `--mqtt-*` flags are no-ops unless `--mqtt` is set. The + // wifi-densepose-sensing-server crate must be built with `--features mqtt` + // for the publisher to do anything; without that feature the flags parse + // but produce a startup WARN. This split lets the binary stay small for + // users who don't need HA integration, while keeping the CLI surface + // stable. + /// Enable MQTT publisher with HA auto-discovery + #[arg(long, env = "RUVIEW_MQTT")] + pub mqtt: bool, + + /// MQTT broker host + #[arg(long, env = "RUVIEW_MQTT_HOST", default_value = "localhost")] + pub mqtt_host: String, + + /// MQTT broker port (defaults: 1883 plain / 8883 with TLS) + #[arg(long, env = "RUVIEW_MQTT_PORT")] + pub mqtt_port: Option, + + /// MQTT username + #[arg(long, env = "RUVIEW_MQTT_USERNAME")] + pub mqtt_username: Option, + + /// Environment variable holding the MQTT password + #[arg(long, default_value = "MQTT_PASSWORD")] + pub mqtt_password_env: String, + + /// MQTT client ID (default: wifi-densepose-) + #[arg(long, env = "RUVIEW_MQTT_CLIENT_ID")] + pub mqtt_client_id: Option, + + /// Discovery topic prefix (ADR-115 §9.2 — accepted: `homeassistant`) + #[arg(long, env = "RUVIEW_MQTT_PREFIX", default_value = "homeassistant")] + pub mqtt_prefix: String, + + /// Enable TLS to the broker + #[arg(long, env = "RUVIEW_MQTT_TLS")] + pub mqtt_tls: bool, + + /// CA bundle for TLS + #[arg(long, value_name = "PATH")] + pub mqtt_ca_file: Option, + + /// Client certificate for mTLS + #[arg(long, value_name = "PATH")] + pub mqtt_client_cert: Option, + + /// Client key for mTLS + #[arg(long, value_name = "PATH")] + pub mqtt_client_key: Option, + + /// Discovery refresh interval (seconds) + #[arg(long, default_value = "600")] + pub mqtt_refresh_secs: u64, + + /// Vitals publish rate (Hz) — HR/BR + #[arg(long, default_value = "0.2")] + pub mqtt_rate_vitals: f64, + + /// Motion publish rate (Hz) + #[arg(long, default_value = "1.0")] + pub mqtt_rate_motion: f64, + + /// Person count publish rate (Hz) + #[arg(long, default_value = "1.0")] + pub mqtt_rate_count: f64, + + /// RSSI publish rate (Hz) + #[arg(long, default_value = "0.1")] + pub mqtt_rate_rssi: f64, + + /// Publish pose keypoints over MQTT (off by default for bandwidth) + #[arg(long)] + pub mqtt_publish_pose: bool, + + /// Pose publish rate (Hz) when --mqtt-publish-pose is set + #[arg(long, default_value = "1.0")] + pub mqtt_rate_pose: f64, + + // ─── ADR-115 §3.10 — Privacy mode ────────────────────────────────────── + /// Strip biometrics (HR/BR/pose) before any MQTT or Matter publish. + /// Discovery for those entities is suppressed entirely — the controller + /// never sees them exist. Implements the ADR-106 primitive-isolation + /// contract at the integration boundary. + #[arg(long, env = "RUVIEW_PRIVACY_MODE")] + pub privacy_mode: bool, + + // ─── ADR-115 §3.11 — Matter Bridge (HA-FABRIC) ───────────────────────── + /// Enable Matter Bridge + #[arg(long, env = "RUVIEW_MATTER")] + pub matter: bool, + + /// Write Matter setup code + QR string to this file on first start + #[arg(long, value_name = "PATH")] + pub matter_setup_file: Option, + + /// Wipe stored Matter fabric credentials before starting + #[arg(long)] + pub matter_reset: bool, + + /// Matter vendor ID (default: dev VID 0xFFF1 per ADR-115 §9.9) + #[arg(long, default_value = "0xFFF1")] + pub matter_vendor_id: String, + + /// Matter product ID (default: 0x8001) + #[arg(long, default_value = "0x8001")] + pub matter_product_id: String, + + // ─── ADR-115 §3.12 — Semantic Inference (HA-MIND) ───────────────────── + /// Enable semantic inference layer (sleeping/distress/room-active/etc). + /// Default ON — primitives are the primary product surface. + #[arg(long, default_value_t = true)] + pub semantic: bool, + + /// Per-primitive thresholds file + #[arg(long, value_name = "PATH")] + pub semantic_thresholds_file: Option, + + /// Zone-tag map (e.g. {"bathroom": ["zone_3"]}) + #[arg(long, value_name = "PATH")] + pub semantic_zones_file: Option, + + /// Days of history for personalised baselines + #[arg(long, default_value = "14")] + pub semantic_baseline_window_days: u32, + + /// Disable a specific semantic primitive (e.g. `sleeping`); repeatable. + /// Valid names: sleeping, distress, room_active, elderly_anomaly, + /// meeting, bathroom, fall_risk, bed_exit, no_movement, multi_room. + #[arg(long = "no-semantic", value_name = "PRIMITIVE")] + pub no_semantic: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + /// MQTT flags default safely (disabled). + #[test] + fn mqtt_defaults_disabled() { + let args = Args::parse_from(["sensing-server"]); + assert!(!args.mqtt, "--mqtt must default to false"); + assert_eq!(args.mqtt_host, "localhost"); + assert_eq!(args.mqtt_prefix, "homeassistant"); // ADR-115 §9.2 + assert_eq!(args.mqtt_refresh_secs, 600); + assert_eq!(args.mqtt_rate_vitals, 0.2); + assert_eq!(args.mqtt_rate_motion, 1.0); + assert_eq!(args.mqtt_rate_count, 1.0); + assert_eq!(args.mqtt_rate_rssi, 0.1); + assert!(!args.mqtt_publish_pose, "pose publish off by default for bandwidth"); + assert_eq!(args.mqtt_rate_pose, 1.0); + assert!(!args.mqtt_tls); + assert!(args.mqtt_username.is_none()); + assert!(args.mqtt_port.is_none()); + } + + /// Privacy mode defaults off so existing deployments don't break; + /// production deployments must opt in. + #[test] + fn privacy_mode_defaults_off() { + let args = Args::parse_from(["sensing-server"]); + assert!(!args.privacy_mode); + } + + /// Matter defaults off; VID is the dev VID per ADR-115 §9.9. + #[test] + fn matter_defaults_off_dev_vid() { + let args = Args::parse_from(["sensing-server"]); + assert!(!args.matter); + assert_eq!(args.matter_vendor_id, "0xFFF1"); + assert_eq!(args.matter_product_id, "0x8001"); + } + + /// Semantic primitives default ON per ADR-115 §3.12. + #[test] + fn semantic_defaults_on() { + let args = Args::parse_from(["sensing-server"]); + assert!(args.semantic); + assert!(args.no_semantic.is_empty()); + assert_eq!(args.semantic_baseline_window_days, 14); + } + + /// All MQTT flags can be set together without conflicts. + #[test] + fn mqtt_all_flags_compose() { + let args = Args::parse_from([ + "sensing-server", + "--mqtt", + "--mqtt-host", "broker.example.com", + "--mqtt-port", "8883", + "--mqtt-username", "ruview", + "--mqtt-prefix", "homeassistant", + "--mqtt-tls", + "--mqtt-refresh-secs", "300", + "--mqtt-rate-vitals", "0.5", + "--mqtt-publish-pose", + "--mqtt-rate-pose", "2.0", + "--privacy-mode", + ]); + assert!(args.mqtt); + assert_eq!(args.mqtt_host, "broker.example.com"); + assert_eq!(args.mqtt_port, Some(8883)); + assert_eq!(args.mqtt_username.as_deref(), Some("ruview")); + assert!(args.mqtt_tls); + assert_eq!(args.mqtt_refresh_secs, 300); + assert_eq!(args.mqtt_rate_vitals, 0.5); + assert!(args.mqtt_publish_pose); + assert_eq!(args.mqtt_rate_pose, 2.0); + assert!(args.privacy_mode); + } + + /// `--no-semantic` is repeatable and accumulates. + #[test] + fn no_semantic_repeatable() { + let args = Args::parse_from([ + "sensing-server", + "--no-semantic", "sleeping", + "--no-semantic", "meeting", + "--no-semantic", "fall_risk", + ]); + assert_eq!(args.no_semantic, vec!["sleeping", "meeting", "fall_risk"]); + } + + // Env-var resolution is covered by clap's own test suite; we don't + // re-test it here because cargo's default parallel runner would race on + // the shared process env. Run `cargo test --test-threads=1` if you need + // local env-var coverage during development. } diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 6fb07fd5..d0c4adab 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -378,6 +378,12 @@ struct NodeState { latest_sync: Option, /// Last time a sync packet from this node was received (for staleness). latest_sync_at: Option, + /// ADR-110 iter 18: EMA-tracked CSI frame rate for this node. + /// Replaces the hardcoded 20 Hz fallback in + /// `mesh_aligned_us_for_csi_frame` once `csi_fps_samples ≥ 5`. + csi_fps_ema: f64, + /// Number of inter-frame deltas observed (need ≥5 before trusting EMA). + csi_fps_samples: u32, /// Latest extracted features for cross-node fusion. latest_features: Option, // ── RuVector Phase 2: Temporal smoothing & coherence gating ── @@ -418,6 +424,59 @@ const NOVELTY_HISTORY_CAPACITY: usize = 64; /// subcarrier ordering / normalisation so banks reject stale data. const NOVELTY_SKETCH_VERSION: u16 = 1; +/// ADR-110 iter 18 — EMA update for per-node CSI fps tracking. +/// +/// Returns the new EMA value, or `None` if the delta is implausible +/// (≤ 0, or > 1 second — likely a connection gap, not a real frame +/// rate sample). α = 1/8 fixed shift, ~8-sample effective window, +/// matching the firmware-side ESP-NOW offset smoother in §A0.10. +/// +/// Free function for testability — every transformation that doesn't +/// touch the rest of `NodeState` lives outside the `impl` block. +pub(crate) fn update_csi_fps_ema(prev_fps: f64, dt_sec: f64) -> Option { + if !(dt_sec > 0.0 && dt_sec < 1.0) { + return None; + } + let instantaneous = 1.0 / dt_sec; + // y[n] = y[n-1] + (x - y[n-1]) / 8 + Some(prev_fps + (instantaneous - prev_fps) / 8.0) +} + +#[cfg(test)] +mod fps_ema_tests { + use super::update_csi_fps_ema; + + #[test] + fn steady_10hz_converges_toward_10() { + let mut fps = 20.0; + for _ in 0..40 { + fps = update_csi_fps_ema(fps, 0.100).unwrap(); + } + assert!((fps - 10.0).abs() < 0.1, + "expected ~10 Hz after 40 samples at 100 ms intervals, got {fps}"); + } + + #[test] + fn steady_20hz_stays_near_20() { + let mut fps = 20.0; + for _ in 0..20 { + fps = update_csi_fps_ema(fps, 0.050).unwrap(); + } + assert!((fps - 20.0).abs() < 0.05, "expected ~20 Hz, got {fps}"); + } + + #[test] + fn nonpositive_dt_rejected() { + assert!(update_csi_fps_ema(15.0, 0.0).is_none()); + assert!(update_csi_fps_ema(15.0, -0.1).is_none()); + } + + #[test] + fn long_gap_rejected_as_implausible() { + assert!(update_csi_fps_ema(20.0, 2.0).is_none()); + } +} + impl NodeState { /// ADR-110 §A0.12 timestamp recovery: given a CSI frame's node-local /// `esp_timer_get_time()` snapshot, return the mesh-aligned epoch @@ -449,8 +508,28 @@ impl NodeState { if seen_at.elapsed() > std::time::Duration::from_secs(9) { return None; } - const CSI_FPS_HZ: f64 = 20.0; - Some(sync.mesh_aligned_us_for_sequence(frame_sequence, CSI_FPS_HZ)) + // Iter 18: use the measured per-node fps once we have ≥5 inter-frame + // samples; until then fall back to the 20 Hz firmware ceiling. The + // §A0.12 capture showed real bench fps ≈ 10, so the measured value + // is significantly more accurate than the constant fallback. + let fps = if self.csi_fps_samples >= 5 { self.csi_fps_ema } else { 20.0 }; + Some(sync.mesh_aligned_us_for_sequence(frame_sequence, fps)) + } + + /// ADR-110 iter 18 — update the per-node observed-fps EMA from a fresh + /// CSI frame arrival. Call once per accepted CSI frame from + /// `udp_receiver_task`. Uses `last_frame_time` as the previous-frame + /// anchor; the first frame after init seeds the timer without producing + /// a sample (no prior dt to measure). + pub(crate) fn observe_csi_frame_arrival(&mut self, now: std::time::Instant) { + if let Some(prev) = self.last_frame_time { + let dt = now.duration_since(prev).as_secs_f64(); + if let Some(new_ema) = update_csi_fps_ema(self.csi_fps_ema, dt) { + self.csi_fps_ema = new_ema; + self.csi_fps_samples = self.csi_fps_samples.saturating_add(1); + } + } + self.last_frame_time = Some(now); } pub(crate) fn new() -> Self { @@ -477,6 +556,8 @@ impl NodeState { edge_vitals: None, latest_sync: None, latest_sync_at: None, + csi_fps_ema: 20.0, + csi_fps_samples: 0, latest_features: None, prev_keypoints: None, motion_energy_history: VecDeque::with_capacity(COHERENCE_WINDOW),