Skip to content

Commit 9df908d

Browse files
authored
Merge pull request #904 from ruvnet/fix/898-mqtt-per-node-devices
fix(mqtt): one Home-Assistant device per node — closes #898
2 parents 3fec676 + 27edf15 commit 9df908d

4 files changed

Lines changed: 172 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Fixed
11+
- **MQTT multi-node deployments now create one Home-Assistant device per node — closes #898.** After the #872 MQTT wiring landed, the JSON→`VitalsSnapshot` bridge hard-coded a single `node_id` (the MQTT client id) and the publisher used a single `OwnedDiscoveryBuilder`, so every physical node collapsed into one device (`identifiers:["wifi_densepose_wifi-densepose-1"]`), contradicting the "one device per node" docs. The bridge now emits one snapshot per node in the sensing update's `nodes[]` (each with its own `node_id` + RSSI, falling back to a single aggregate snapshot for wifi/simulate sources), and the publisher derives a per-node builder (`OwnedDiscoveryBuilder::for_node`) that publishes discovery + availability lazily on first sight of each `node_id` and routes state to per-node topics — yielding N distinct HA devices with per-node availability/LWT. Unit-tested (distinct nodes → distinct `wifi_densepose_<node>` identifiers); 71 MQTT tests pass.
1112
- **Person count no longer pinned to 1 — addresses #803.** The aggregate occupancy reported by the sensing server was derived from `smoothed_person_score`, an EMA-smoothed *activity* score (amplitude variance / motion / spectral energy). That score saturates near a single occupant — one moving person maxes it out — so it cannot discriminate occupancy *count* and stayed clamped at 1 across S3/C6 and the Python/Docker/Rust servers. Meanwhile the count-aware per-node estimates the ESP32 paths already compute (firmware `n_persons`, and the DynamicMinCut `corr_persons`) were stashed in `NodeState::prev_person_count` and then **discarded** by the aggregator (same dead-wiring class as #872). The aggregator now takes `max(activity_count, node_max)` via a unit-tested `aggregate_person_count` helper, so a node positively estimating 2–3 occupants is surfaced instead of overwritten. The fix can only ever *raise* the count when a node reports more people, so the single-occupant case is provably never inflated (regression-guarded by test). **Second half:** the pure-CSI per-node path itself clamped its own estimate — the DynamicMinCut occupancy (`estimate_persons_from_correlation`, 0–3) was mapped to a score via `corr_persons / 3.0`, putting 2 people at 0.667, *just under* the 0.70 up-threshold of `score_to_person_count`, so the per-node count never climbed past 1 (so `node_max` was also stuck at 1 for CSI-only nodes). Replaced it with a threshold-aligned `corr_persons_to_score` mapping (1→0.40, 2→0.74, 3→0.96) whose steady state round-trips back to the same count through the EMA + hysteresis, while still gating transient noise. A convergence test replays the exact EMA loop to prove min-cut=2 now reports 2 (and documents that the old `/3.0` mapping reported 1). Full multi-person accuracy still depends on the underlying estimator quality; this removes the two server-side clamps that masked it. 586 sensing-server tests pass.
1213
- **MQTT publisher now actually runs (`--mqtt`) — closes #872.** The `--mqtt*` flags were defined only in `cli::Args` (dead code, referenced nowhere) while the binary parses a *separate* `main::Args` with no mqtt fields, and `main.rs` never started the `mqtt::` publisher — so MQTT/Home-Assistant integration was completely unwired (`--mqtt` errored as an unexpected argument, and even with the Docker image's `--features mqtt` build the publisher never ran). Earlier attempts chased a Docker *rebuild*; the real cause was disconnected *code*. Extracted the flags into a shared `cli::MqttArgs` (`#[command(flatten)]` into both structs), spawn the publisher on `--mqtt`, and bridge the JSON sensing broadcast into the typed `VitalsSnapshot` stream with a defensive `serde_json::Value` mapping. Verified end-to-end against `mosquitto`: 20 HA auto-discovery entities + live state (presence/person-count/…). 577 (default) / 580 (`--features mqtt`) tests pass.
1314

v2/crates/wifi-densepose-sensing-server/src/main.rs

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6213,24 +6213,44 @@ async fn main() {
62136213
Some(_) => 1.0,
62146214
None => 0.0,
62156215
};
6216-
let snap = mqtt::state::VitalsSnapshot {
6217-
node_id: node_id.clone(),
6218-
timestamp_ms: (v["timestamp"].as_f64().unwrap_or(0.0) * 1000.0) as i64,
6216+
let ts = (v["timestamp"].as_f64().unwrap_or(0.0) * 1000.0) as i64;
6217+
let conf = cls["confidence"].as_f64().unwrap_or(0.0);
6218+
let presence_score = if presence { conf.max(0.0) } else { 0.0 };
6219+
let breathing = vit["breathing_rate_bpm"].as_f64();
6220+
let hr = vit["heart_rate_bpm"].as_f64();
6221+
// #898: emit one snapshot per physical node so each
6222+
// surfaces as its own Home-Assistant device (with
6223+
// its own RSSI + availability). Falls back to a
6224+
// single aggregate snapshot when there is no
6225+
// per-node data (e.g. wifi / simulate sources).
6226+
let mk = |nid: String, rssi: Option<f64>| mqtt::state::VitalsSnapshot {
6227+
node_id: nid,
6228+
timestamp_ms: ts,
62196229
presence,
62206230
motion,
6221-
presence_score: if presence {
6222-
cls["confidence"].as_f64().unwrap_or(1.0)
6223-
} else {
6224-
0.0
6225-
},
6226-
breathing_rate_bpm: vit["breathing_rate_bpm"].as_f64(),
6227-
heartrate_bpm: vit["heart_rate_bpm"].as_f64(),
6231+
presence_score,
6232+
breathing_rate_bpm: breathing,
6233+
heartrate_bpm: hr,
62286234
n_persons,
6229-
rssi_dbm: v["nodes"][0]["rssi_dbm"].as_f64(),
6230-
vital_confidence: cls["confidence"].as_f64().unwrap_or(0.0),
6235+
rssi_dbm: rssi,
6236+
vital_confidence: conf,
62316237
..Default::default()
62326238
};
6233-
let _ = vtx.send(snap);
6239+
match v["nodes"].as_array() {
6240+
Some(arr) if !arr.is_empty() => {
6241+
for node in arr {
6242+
let n = node["node_id"].as_u64().unwrap_or(0);
6243+
let nid = format!("{node_id}-node{n}");
6244+
let _ = vtx.send(mk(nid, node["rssi_dbm"].as_f64()));
6245+
}
6246+
}
6247+
_ => {
6248+
let _ = vtx.send(mk(
6249+
node_id.clone(),
6250+
v["nodes"][0]["rssi_dbm"].as_f64(),
6251+
));
6252+
}
6253+
}
62346254
}
62356255
});
62366256
tracing::info!("MQTT publisher started -> {host}:{port}");

v2/crates/wifi-densepose-sensing-server/src/mqtt/publisher.rs

Lines changed: 107 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,23 @@ impl OwnedDiscoveryBuilder {
117117
via_device: self.via_device.as_deref(),
118118
}
119119
}
120+
121+
/// Derive a per-node builder from this base (issue #898). Each physical
122+
/// RuView node must surface as its own Home-Assistant device — the base
123+
/// builder's `node_id` (the MQTT client id) is replaced with the actual
124+
/// node id, giving a distinct `wifi_densepose_<node>` device identifier
125+
/// and a per-node friendly name, instead of collapsing every node into a
126+
/// single hard-coded device.
127+
pub fn for_node(&self, node_id: &str) -> OwnedDiscoveryBuilder {
128+
OwnedDiscoveryBuilder {
129+
discovery_prefix: self.discovery_prefix.clone(),
130+
node_id: node_id.to_string(),
131+
node_friendly_name: Some(format!("RuView node {node_id}")),
132+
sw_version: self.sw_version.clone(),
133+
model: self.model.clone(),
134+
via_device: self.via_device.clone(),
135+
}
136+
}
120137
}
121138

122139
/// Core run loop. Pumps the broadcast channel + the MQTT event loop in
@@ -129,20 +146,19 @@ async fn run(
129146
let opts = build_mqtt_options(&cfg);
130147
let (client, mut eventloop): (AsyncClient, EventLoop) = AsyncClient::new(opts, 256);
131148

132-
let builder_borrowed = builder_owned.as_borrowed();
133149
let entities = DiscoveryBuilder::enabled_entities(
134150
cfg.privacy_mode,
135151
cfg.publish_pose,
136152
&[], // no_semantic — wire from cli::Args in P3.5
137153
);
138154

139-
if let Err(e) = publish_all_discovery(&client, &builder_borrowed, &entities).await {
140-
warn!("[mqtt] initial discovery publish failed: {e}");
141-
}
142-
let avail = NodeAvailability::for_builder(&builder_borrowed, &entities);
143-
if let Err(e) = publish_availability(&client, &avail, "online").await {
144-
warn!("[mqtt] initial availability publish failed: {e}");
145-
}
155+
// #898: one Home-Assistant device per node. Discovery + availability are
156+
// published lazily the first time a snapshot for a given node_id arrives;
157+
// each node's builder + availability are retained here for heartbeats and
158+
// the offline LWT. (Previously a single hard-coded builder collapsed every
159+
// node into one device.)
160+
let mut nodes: std::collections::HashMap<String, (OwnedDiscoveryBuilder, NodeAvailability)> =
161+
std::collections::HashMap::new();
146162

147163
let mut rate_limiter = RateLimiter::new();
148164
let mut last_heartbeat = Instant::now();
@@ -179,14 +195,20 @@ async fn run(
179195
// Periodic heartbeat / discovery refresh.
180196
_ = tokio::time::sleep(Duration::from_secs(1)) => {
181197
if last_heartbeat.elapsed() >= AVAILABILITY_HEARTBEAT {
182-
if let Err(e) = publish_availability(&client, &avail, "online").await {
183-
warn!("[mqtt] heartbeat publish failed: {e}");
198+
for (_, na) in nodes.values() {
199+
if let Err(e) = publish_availability(&client, na, "online").await {
200+
warn!("[mqtt] heartbeat publish failed: {e}");
201+
}
184202
}
185203
last_heartbeat = Instant::now();
186204
}
187205
if last_refresh.elapsed() >= Duration::from_secs(cfg.refresh_secs) {
188-
if let Err(e) = publish_all_discovery(&client, &builder_borrowed, &entities).await {
189-
warn!("[mqtt] discovery refresh failed: {e}");
206+
for (nb, _) in nodes.values() {
207+
if let Err(e) =
208+
publish_all_discovery(&client, &nb.as_borrowed(), &entities).await
209+
{
210+
warn!("[mqtt] discovery refresh failed: {e}");
211+
}
190212
}
191213
last_refresh = Instant::now();
192214
}
@@ -197,18 +219,39 @@ async fn run(
197219
match recv {
198220
Ok(snap) => {
199221
let elapsed = start_instant.elapsed();
200-
publish_snapshot(&client, &builder_borrowed, &snap, &cfg, &mut rate_limiter, elapsed).await;
222+
// #898: on first sight of a node_id, publish that
223+
// node's discovery + availability; then route its
224+
// state to per-node topics.
225+
if !nodes.contains_key(&snap.node_id) {
226+
let nb = builder_owned.for_node(&snap.node_id);
227+
let borrowed = nb.as_borrowed();
228+
if let Err(e) =
229+
publish_all_discovery(&client, &borrowed, &entities).await
230+
{
231+
warn!("[mqtt] node {} discovery failed: {e}", snap.node_id);
232+
}
233+
let na = NodeAvailability::for_builder(&borrowed, &entities);
234+
if let Err(e) = publish_availability(&client, &na, "online").await {
235+
warn!("[mqtt] node {} availability failed: {e}", snap.node_id);
236+
}
237+
nodes.insert(snap.node_id.clone(), (nb, na));
238+
}
239+
let borrowed = nodes[&snap.node_id].0.as_borrowed();
240+
publish_snapshot(&client, &borrowed, &snap, &cfg, &mut rate_limiter, elapsed).await;
201241
}
202242
Err(broadcast::error::RecvError::Lagged(n)) => {
203243
warn!("[mqtt] lagged behind broadcast by {n} messages — dropped");
204244
}
205245
Err(broadcast::error::RecvError::Closed) => {
206246
info!("[mqtt] broadcast channel closed, draining");
207-
// Publish offline before exit.
208-
let _ = publish_availability(&client, &avail, "offline").await;
247+
// Publish offline for every known node before exit.
248+
for (_, na) in nodes.values() {
249+
let _ = publish_availability(&client, na, "offline").await;
250+
}
209251
let _ = client.disconnect().await;
210252
return;
211253
}
254+
212255
}
213256
}
214257
}
@@ -296,3 +339,52 @@ async fn publish_state(client: &AsyncClient, m: &StateMessage) -> Result<(), Cli
296339
};
297340
client.publish(&m.topic, qos, m.retain, m.payload.clone()).await
298341
}
342+
343+
#[cfg(test)]
344+
mod per_node_device_tests {
345+
//! Issue #898 — each physical node must surface as its own Home-Assistant
346+
//! device, not collapse into one hard-coded device.
347+
use super::*;
348+
349+
fn base() -> OwnedDiscoveryBuilder {
350+
OwnedDiscoveryBuilder {
351+
discovery_prefix: "homeassistant".into(),
352+
node_id: "wifi-densepose-1".into(),
353+
node_friendly_name: Some("RuView".into()),
354+
sw_version: "0.0.0".into(),
355+
model: "test".into(),
356+
via_device: None,
357+
}
358+
}
359+
360+
fn device_identifiers(b: &OwnedDiscoveryBuilder) -> Vec<String> {
361+
b.as_borrowed().build(EntityKind::Presence).device.identifiers
362+
}
363+
364+
#[test]
365+
fn for_node_overrides_node_id_and_friendly_name() {
366+
let n = base().for_node("node-A");
367+
assert_eq!(n.node_id, "node-A");
368+
assert_eq!(n.node_friendly_name.as_deref(), Some("RuView node node-A"));
369+
}
370+
371+
#[test]
372+
fn distinct_nodes_yield_distinct_ha_device_identifiers() {
373+
let b = base();
374+
let a = device_identifiers(&b.for_node("node-A"));
375+
let c = device_identifiers(&b.for_node("node-B"));
376+
assert_eq!(a, vec!["wifi_densepose_node-A".to_string()]);
377+
assert_eq!(c, vec!["wifi_densepose_node-B".to_string()]);
378+
assert_ne!(a, c, "#898: two nodes must not collapse into one device");
379+
}
380+
381+
#[test]
382+
fn single_node_keeps_a_stable_identity() {
383+
// Two snapshots from the same node map to the same device.
384+
let b = base();
385+
assert_eq!(
386+
device_identifiers(&b.for_node("node-7")),
387+
device_identifiers(&b.for_node("node-7"))
388+
);
389+
}
390+
}

v2/crates/wifi-densepose-sensing-server/tests/mqtt_integration.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,12 +171,28 @@ async fn discovery_topics_appear_on_broker() {
171171
// Spawn the publisher.
172172
let cfg = make_cfg(port, false, "discovery");
173173
let builder = make_builder("inttest1");
174-
let (_tx, rx) = broadcast::channel::<VitalsSnapshot>(32);
174+
let (tx, rx) = broadcast::channel::<VitalsSnapshot>(32);
175175
let _handle = spawn(cfg, builder, rx);
176176

177+
// #898: discovery is now published per-node the first time a snapshot for
178+
// that node_id arrives (not eagerly at startup). Drive snapshots for
179+
// "inttest1" throughout the window so its device's discovery lands — same
180+
// pattern as state_messages_published_on_snapshot_broadcast.
181+
let tx_bg = tx.clone();
182+
let drive = tokio::spawn(async move {
183+
for _ in 0..60 {
184+
let _ = tx_bg.send(VitalsSnapshot {
185+
node_id: "inttest1".into(),
186+
..Default::default()
187+
});
188+
tokio::time::sleep(Duration::from_millis(200)).await;
189+
}
190+
});
191+
177192
// Drain the subscriber for up to 6 s — enough for initial discovery
178193
// + first availability publication.
179194
let msgs = collect_published(&mut sub_loop, Duration::from_secs(6)).await;
195+
drive.abort();
180196
let _ = sub.disconnect().await;
181197

182198
// Assertions: at least the presence + heart_rate + fall discovery
@@ -221,10 +237,23 @@ async fn privacy_mode_suppresses_biometric_discovery() {
221237

222238
let cfg = make_cfg(port, /* privacy_mode = */ true, "privacy");
223239
let builder = make_builder("inttest2");
224-
let (_tx, rx) = broadcast::channel::<VitalsSnapshot>(32);
240+
let (tx, rx) = broadcast::channel::<VitalsSnapshot>(32);
225241
let _handle = spawn(cfg, builder, rx);
226242

243+
// #898: per-node discovery is triggered by a snapshot for that node_id.
244+
let tx_bg = tx.clone();
245+
let drive = tokio::spawn(async move {
246+
for _ in 0..60 {
247+
let _ = tx_bg.send(VitalsSnapshot {
248+
node_id: "inttest2".into(),
249+
..Default::default()
250+
});
251+
tokio::time::sleep(Duration::from_millis(200)).await;
252+
}
253+
});
254+
227255
let msgs = collect_published(&mut sub_loop, Duration::from_secs(6)).await;
256+
drive.abort();
228257
let _ = sub.disconnect().await;
229258

230259
let topics: Vec<&str> = msgs.iter().map(|(t, _, _)| t.as_str()).collect();

0 commit comments

Comments
 (0)