Summary
With more than one node, the top-level classification in GET /api/v1/sensing/latest is not an aggregate — it is whatever the last arriving vitals packet said. With nodes that disagree, the field flips at packet rate (~40/s here), so any consumer polling it sees a coin flip.
Setup
3 × ESP32-S3-DevKitC-1 nodes (node_id 1/2/3), firmware built from source, TDM slots 0/1/2, all streaming CSI over UDP :5005 to sensing-server (Docker image ruvnet/wifi-densepose:latest).
Observed
A person sat still in the room for the whole sample. 60 API polls, 2 s apart:
| Field |
Result |
node_features[node_id=1].classification.presence |
true — 60/60 |
node_features[node_id=2].classification.presence |
false — 60/60 |
top-level classification.presence |
37 × true, 23 × false |
Same effect downstream: a Home Assistant entity polling this field every 10 s had a median state duration of exactly 10 s — it changed almost every poll. Recorder history over 2.5 h: 24054 on / 24053 off.
With a single node connected, the field was stable (352 consecutive polls, no flip). The flapping started the moment nodes 2 and 3 came online.
Cause
main.rs:5975 (and the same call at :6334):
let mut classification = classify_vitals(vitals.motion, vitals.presence, vitals.presence_score);
vitals is the packet currently being processed, i.e. one node. fuse_multi_node_features() right next to it fuses features, but classification never goes through it. The result is stored via s.latest_update = Some(update) (main.rs:6063), overwritten by every packet, and served as-is by the /api/v1/sensing/latest handler (main.rs:3538).
By contrast node_features[] is built by build_node_features() (main.rs:953) from the persistent per-node ns.current_motion_level, which is why per-node values are stable while the top-level one is not.
The per-node hysteresis and clear-debounce added in #996 (firmware/esp32-csi-node/main/edge_processing.c:1167, presence_flag_update) cannot help here: it smooths the flag inside each node, while what flips is which node was last.
Suggested fix
Derive the top-level classification from all non-stale entries in node_states — the same source build_node_features() already uses — rather than from the packet in hand. A presence OR (or an N-of-M vote) plus a moving > still > absent ladder matches what classify_vitals already does for a single node.
Workaround for others hitting this
Ignore top-level classification and aggregate node_features[] yourself. Filter out entries with stale: true first, otherwise a node that drops off keeps contributing its last known presence: true forever.
Summary
With more than one node, the top-level
classificationinGET /api/v1/sensing/latestis not an aggregate — it is whatever the last arriving vitals packet said. With nodes that disagree, the field flips at packet rate (~40/s here), so any consumer polling it sees a coin flip.Setup
3 × ESP32-S3-DevKitC-1 nodes (node_id 1/2/3), firmware built from source, TDM slots 0/1/2, all streaming CSI over UDP :5005 to
sensing-server(Docker imageruvnet/wifi-densepose:latest).Observed
A person sat still in the room for the whole sample. 60 API polls, 2 s apart:
node_features[node_id=1].classification.presencetrue— 60/60node_features[node_id=2].classification.presencefalse— 60/60classification.presencetrue, 23 ×falseSame effect downstream: a Home Assistant entity polling this field every 10 s had a median state duration of exactly 10 s — it changed almost every poll. Recorder history over 2.5 h: 24054
on/ 24053off.With a single node connected, the field was stable (352 consecutive polls, no flip). The flapping started the moment nodes 2 and 3 came online.
Cause
main.rs:5975(and the same call at:6334):vitalsis the packet currently being processed, i.e. one node.fuse_multi_node_features()right next to it fusesfeatures, butclassificationnever goes through it. The result is stored vias.latest_update = Some(update)(main.rs:6063), overwritten by every packet, and served as-is by the/api/v1/sensing/latesthandler (main.rs:3538).By contrast
node_features[]is built bybuild_node_features()(main.rs:953) from the persistent per-nodens.current_motion_level, which is why per-node values are stable while the top-level one is not.The per-node hysteresis and clear-debounce added in #996 (
firmware/esp32-csi-node/main/edge_processing.c:1167,presence_flag_update) cannot help here: it smooths the flag inside each node, while what flips is which node was last.Suggested fix
Derive the top-level
classificationfrom all non-stale entries innode_states— the same sourcebuild_node_features()already uses — rather than from the packet in hand. A presence OR (or an N-of-M vote) plus amoving > still > absentladder matches whatclassify_vitalsalready does for a single node.Workaround for others hitting this
Ignore top-level
classificationand aggregatenode_features[]yourself. Filter out entries withstale: truefirst, otherwise a node that drops off keeps contributing its last knownpresence: trueforever.