fix(sensing-server): wire MQTT publisher into the binary — closes #872
#872 reported '--mqtt: unexpected argument' on the Docker image; prior attempts chased a Docker *rebuild*, but the real cause was disconnected *code*: the --mqtt* flags lived only in cli::Args (dead code — referenced nowhere), while the binary parses a separate main::Args with no mqtt fields, and main.rs never declared/started the mqtt:: publisher. So MQTT was fully unwired: flags didn't parse, and the publisher never ran. Fix: - Extract the mqtt + privacy flags into a shared (#[derive(clap::Args)]); retarget mqtt::config::{from_args,build_tls} to it. - #[command(flatten)] MqttArgs into the binary's main::Args (using the *lib* crate's type so it matches from_args), so --mqtt* now parse. - Spawn the publisher on --mqtt: build MqttConfig, validate, and bridge the existing JSON sensing broadcast into the typed VitalsSnapshot stream the publisher consumes (defensive serde_json::Value mapping — absent fields default, never wrong values). #[cfg(feature=mqtt)]-gated; without the feature --mqtt WARNs and no-ops (documented contract). Fix the mqtt_publisher example for the new signature. Verified end-to-end against local mosquitto: publisher connects and emits 20 HA auto-discovery entities + live state (presence ON, person_count, …). Tests: 577 pass default / 580 pass --features mqtt / 0 fail; both configs build. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
edbe57378a
commit
a3f80b0cda
|
|
@ -47,7 +47,7 @@ use tokio::sync::broadcast;
|
||||||
#[cfg(feature = "mqtt")]
|
#[cfg(feature = "mqtt")]
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
#[cfg(feature = "mqtt")]
|
#[cfg(feature = "mqtt")]
|
||||||
use wifi_densepose_sensing_server::cli::Args;
|
use wifi_densepose_sensing_server::cli::MqttArgs;
|
||||||
#[cfg(feature = "mqtt")]
|
#[cfg(feature = "mqtt")]
|
||||||
use wifi_densepose_sensing_server::mqtt::{
|
use wifi_densepose_sensing_server::mqtt::{
|
||||||
config::MqttConfig,
|
config::MqttConfig,
|
||||||
|
|
@ -61,7 +61,15 @@ use wifi_densepose_sensing_server::mqtt::{
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
let args = Args::parse();
|
let args = {
|
||||||
|
use clap::Parser;
|
||||||
|
#[derive(Parser)]
|
||||||
|
struct W {
|
||||||
|
#[command(flatten)]
|
||||||
|
m: MqttArgs,
|
||||||
|
}
|
||||||
|
W::parse().m
|
||||||
|
};
|
||||||
|
|
||||||
if !args.mqtt {
|
if !args.mqtt {
|
||||||
eprintln!("This example requires --mqtt. Aborting.");
|
eprintln!("This example requires --mqtt. Aborting.");
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,89 @@
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// MQTT publisher (HA auto-discovery) + privacy-mode flags, shared via
|
||||||
|
/// `#[command(flatten)]` by both `cli::Args` and the binary's `main::Args`
|
||||||
|
/// so the `--mqtt*` flags reach the actual `Args::parse()` the server uses
|
||||||
|
/// (the publisher in `mqtt::` is keyed off this group). ADR-115 §3.8/§3.10.
|
||||||
|
#[derive(clap::Args, Debug, Clone)]
|
||||||
|
pub struct MqttArgs {
|
||||||
|
/// 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<u16>,
|
||||||
|
|
||||||
|
/// MQTT username
|
||||||
|
#[arg(long, env = "RUVIEW_MQTT_USERNAME")]
|
||||||
|
pub mqtt_username: Option<String>,
|
||||||
|
|
||||||
|
/// Environment variable holding the MQTT password
|
||||||
|
#[arg(long, default_value = "MQTT_PASSWORD")]
|
||||||
|
pub mqtt_password_env: String,
|
||||||
|
|
||||||
|
/// MQTT client ID (default: wifi-densepose-<pid>)
|
||||||
|
#[arg(long, env = "RUVIEW_MQTT_CLIENT_ID")]
|
||||||
|
pub mqtt_client_id: Option<String>,
|
||||||
|
|
||||||
|
/// 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<PathBuf>,
|
||||||
|
|
||||||
|
/// Client certificate for mTLS
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
pub mqtt_client_cert: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Client key for mTLS
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
pub mqtt_client_key: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
|
||||||
|
/// Strip biometrics (HR/BR/pose) before any MQTT/Matter publish (ADR-115 §3.10).
|
||||||
|
#[arg(long, env = "RUVIEW_PRIVACY_MODE")]
|
||||||
|
pub privacy_mode: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// CLI arguments for the sensing server.
|
/// CLI arguments for the sensing server.
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(name = "sensing-server", about = "WiFi-DensePose sensing server")]
|
#[command(name = "sensing-server", about = "WiFi-DensePose sensing server")]
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,13 @@ struct Args {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
disable_host_validation: bool,
|
disable_host_validation: bool,
|
||||||
|
|
||||||
|
/// MQTT publisher (HA auto-discovery) + privacy-mode flags (ADR-115).
|
||||||
|
/// Flattened so `--mqtt*` reach the binary's parser and the publisher
|
||||||
|
/// in `mqtt::` is actually started (fixes #872). Uses the *lib* crate's
|
||||||
|
/// `MqttArgs` type so it's compatible with `mqtt::config::from_args`.
|
||||||
|
#[command(flatten)]
|
||||||
|
mqtt_opts: wifi_densepose_sensing_server::cli::MqttArgs,
|
||||||
|
|
||||||
/// Data source: auto, wifi, esp32, simulate
|
/// Data source: auto, wifi, esp32, simulate
|
||||||
#[arg(long, default_value = "auto")]
|
#[arg(long, default_value = "auto")]
|
||||||
source: String,
|
source: String,
|
||||||
|
|
@ -5985,6 +5992,84 @@ async fn main() {
|
||||||
// consumed by `/ws/introspection`. Same ring size as `tx` (256) — slow
|
// consumed by `/ws/introspection`. Same ring size as `tx` (256) — slow
|
||||||
// clients drop oldest, identical backpressure shape.
|
// clients drop oldest, identical backpressure shape.
|
||||||
let (intro_tx, _) = broadcast::channel::<String>(256);
|
let (intro_tx, _) = broadcast::channel::<String>(256);
|
||||||
|
|
||||||
|
// #872: actually start the MQTT publisher when `--mqtt` is set. The publisher
|
||||||
|
// (mqtt::) consumes a typed VitalsSnapshot stream; we bridge the existing JSON
|
||||||
|
// sensing broadcast into it with a defensive serde_json::Value mapping (absent
|
||||||
|
// fields default — never publish wrong values). Gated on the `mqtt` feature
|
||||||
|
// (the Docker image is built `--features mqtt`); without it `--mqtt` WARNs and
|
||||||
|
// no-ops, matching the documented contract.
|
||||||
|
if args.mqtt_opts.mqtt {
|
||||||
|
#[cfg(feature = "mqtt")]
|
||||||
|
{
|
||||||
|
use wifi_densepose_sensing_server::mqtt;
|
||||||
|
let mcfg = std::sync::Arc::new(mqtt::config::MqttConfig::from_args(&args.mqtt_opts));
|
||||||
|
match mcfg.validate() {
|
||||||
|
Ok(()) => {
|
||||||
|
let node_id = mcfg.client_id.clone();
|
||||||
|
let builder = mqtt::publisher::OwnedDiscoveryBuilder {
|
||||||
|
discovery_prefix: mcfg.discovery_prefix.clone(),
|
||||||
|
node_id: node_id.clone(),
|
||||||
|
node_friendly_name: Some("RuView".to_string()),
|
||||||
|
sw_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
model: "RuView WiFi Sensing".to_string(),
|
||||||
|
via_device: None,
|
||||||
|
};
|
||||||
|
let (vtx, vrx) = broadcast::channel::<mqtt::state::VitalsSnapshot>(64);
|
||||||
|
let (host, port) = (mcfg.host.clone(), mcfg.port);
|
||||||
|
mqtt::publisher::spawn(mcfg, builder, vrx);
|
||||||
|
let mut jrx = tx.subscribe();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok(json) = jrx.recv().await {
|
||||||
|
let Ok(v) = serde_json::from_str::<serde_json::Value>(&json) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let cls = &v["classification"];
|
||||||
|
let vit = &v["vital_signs"];
|
||||||
|
let presence = cls["presence"].as_bool().unwrap_or(false);
|
||||||
|
let n_persons = v["persons"]
|
||||||
|
.as_array()
|
||||||
|
.map(|a| a.len() as u32)
|
||||||
|
.or_else(|| v["estimated_persons"].as_u64().map(|x| x as u32))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let motion = match cls["motion_level"].as_str() {
|
||||||
|
Some("none") | Some("still") | Some("idle") | Some("") => 0.0,
|
||||||
|
Some(_) => 1.0,
|
||||||
|
None => 0.0,
|
||||||
|
};
|
||||||
|
let snap = mqtt::state::VitalsSnapshot {
|
||||||
|
node_id: node_id.clone(),
|
||||||
|
timestamp_ms: (v["timestamp"].as_f64().unwrap_or(0.0) * 1000.0) as i64,
|
||||||
|
presence,
|
||||||
|
motion,
|
||||||
|
presence_score: if presence {
|
||||||
|
cls["confidence"].as_f64().unwrap_or(1.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
},
|
||||||
|
breathing_rate_bpm: vit["breathing_rate_bpm"].as_f64(),
|
||||||
|
heartrate_bpm: vit["heart_rate_bpm"].as_f64(),
|
||||||
|
n_persons,
|
||||||
|
rssi_dbm: v["nodes"][0]["rssi_dbm"].as_f64(),
|
||||||
|
vital_confidence: cls["confidence"].as_f64().unwrap_or(0.0),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let _ = vtx.send(snap);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tracing::info!("MQTT publisher started -> {host}:{port}");
|
||||||
|
}
|
||||||
|
Err(e) => tracing::error!("MQTT config invalid: {e}; publisher not started"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "mqtt"))]
|
||||||
|
tracing::warn!(
|
||||||
|
"--mqtt set but this binary was built without the `mqtt` feature; the publisher is a \
|
||||||
|
no-op. Use the official Docker image (built `--features mqtt`) or rebuild with \
|
||||||
|
`cargo build -p wifi-densepose-sensing-server --features mqtt`."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let state: SharedState = Arc::new(RwLock::new(AppStateInner {
|
let state: SharedState = Arc::new(RwLock::new(AppStateInner {
|
||||||
latest_update: None,
|
latest_update: None,
|
||||||
rssi_history: VecDeque::new(),
|
rssi_history: VecDeque::new(),
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ impl MqttConfig {
|
||||||
/// `hostname()` via the `gethostname` crate if `mqtt_client_id` was
|
/// `hostname()` via the `gethostname` crate if `mqtt_client_id` was
|
||||||
/// not supplied — we don't add a dep here, we let the publisher
|
/// not supplied — we don't add a dep here, we let the publisher
|
||||||
/// supply the default lazily.
|
/// supply the default lazily.
|
||||||
pub fn from_args(args: &crate::cli::Args) -> Self {
|
pub fn from_args(args: &crate::cli::MqttArgs) -> Self {
|
||||||
let password = std::env::var(&args.mqtt_password_env).ok();
|
let password = std::env::var(&args.mqtt_password_env).ok();
|
||||||
let port = args.mqtt_port.unwrap_or(if args.mqtt_tls { 8883 } else { 1883 });
|
let port = args.mqtt_port.unwrap_or(if args.mqtt_tls { 8883 } else { 1883 });
|
||||||
let tls = build_tls(args);
|
let tls = build_tls(args);
|
||||||
|
|
@ -135,7 +135,7 @@ impl MqttConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_tls(args: &crate::cli::Args) -> TlsConfig {
|
fn build_tls(args: &crate::cli::MqttArgs) -> TlsConfig {
|
||||||
if !args.mqtt_tls {
|
if !args.mqtt_tls {
|
||||||
return TlsConfig::Off;
|
return TlsConfig::Off;
|
||||||
}
|
}
|
||||||
|
|
@ -186,8 +186,14 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
fn parse(args: &[&str]) -> crate::cli::Args {
|
fn parse(args: &[&str]) -> crate::cli::MqttArgs {
|
||||||
crate::cli::Args::parse_from(std::iter::once("sensing-server").chain(args.iter().copied()))
|
use clap::Parser;
|
||||||
|
#[derive(Parser)]
|
||||||
|
struct W {
|
||||||
|
#[command(flatten)]
|
||||||
|
m: crate::cli::MqttArgs,
|
||||||
|
}
|
||||||
|
W::parse_from(std::iter::once("sensing-server").chain(args.iter().copied())).m
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue