Skip to content

sensing-server: top-level classification is the last packet's node, not an aggregate — presence flaps at packet rate with 2+ nodes #1554

Description

@danscMax

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions