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..5addceec 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/cli.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/cli.rs @@ -102,4 +102,216 @@ 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) ────────────────────────── + /// 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"); + 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); + assert_eq!(args.mqtt_rate_pose, 1.0); + assert!(!args.mqtt_tls); + assert!(args.mqtt_username.is_none()); + assert!(args.mqtt_port.is_none()); + } + + #[test] + fn privacy_mode_defaults_off() { + let args = Args::parse_from(["sensing-server"]); + assert!(!args.privacy_mode); + } + + #[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"); + } + + #[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); + } + + #[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); + } + + #[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"]); + } }