docs/adr/ADR-068-per-node-state-pipeline.md
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-03-27 |
| Authors | rUv, claude-flow |
| Drivers | #249, #237, #276, #282 |
| Supersedes | — |
The sensing server (wifi-densepose-sensing-server) was originally designed for
single-node operation. When multiple ESP32 nodes send CSI frames simultaneously,
all data is mixed into a single shared pipeline:
frame_history VecDeque for all nodessmoothed_person_score / smoothed_motion / vital sign buffersThis means the classification, person count, and vital signs reported to the UI are an uncontrolled aggregate of all nodes' data. The result: the detection window shows identical output regardless of how many nodes are deployed, where people stand, or how many people are in the room (#249 — 24 comments, the most reported issue).
Investigation of AppStateInner (main.rs lines 279-367) confirmed:
| Shared field | Impact |
|---|---|
frame_history | Temporal analysis mixes all nodes' CSI data |
smoothed_person_score | Person count aggregates all nodes |
smoothed_motion | Motion classification undifferentiated |
smoothed_hr / br | Vital signs are global, not per-node |
baseline_motion | Adaptive baseline learned from mixed data |
debounce_counter | All nodes share debounce state |
Introduce per-node state tracking via a HashMap<u8, NodeState> in
AppStateInner. Each ESP32 node (identified by its node_id byte) gets an
independent sensing pipeline with its own temporal history, smoothing buffers,
baseline, and classification state.
┌─────────────────────────────────────────┐
UDP frames │ AppStateInner │
───────────► │ │
node_id=1 ──► │ node_states: HashMap<u8, NodeState> │
node_id=2 ──► │ ├── 1: NodeState { frame_history, │
node_id=3 ──► │ │ smoothed_motion, vitals, ... }│
│ ├── 2: NodeState { ... } │
│ └── 3: NodeState { ... } │
│ │
│ ┌── Per-Node Pipeline ──┐ │
│ │ extract_features() │ │
│ │ smooth_and_classify() │ │
│ │ smooth_vitals() │ │
│ │ score_to_person_count()│ │
│ └────────────────────────┘ │
│ │
│ ┌── Multi-Node Fusion ──┐ │
│ │ Aggregate person count │ │
│ │ Per-node classification│ │
│ │ All-nodes WebSocket msg│ │
│ └────────────────────────┘ │
│ │
│ ──► WebSocket broadcast (sensing_update) │
└─────────────────────────────────────────┘
struct NodeState {
frame_history: VecDeque<Vec<f64>>,
smoothed_person_score: f64,
prev_person_count: usize,
smoothed_motion: f64,
current_motion_level: String,
debounce_counter: u32,
debounce_candidate: String,
baseline_motion: f64,
baseline_frames: u64,
smoothed_hr: f64,
smoothed_br: f64,
smoothed_hr_conf: f64,
smoothed_br_conf: f64,
hr_buffer: VecDeque<f64>,
br_buffer: VecDeque<f64>,
rssi_history: VecDeque<f64>,
vital_detector: VitalSignDetector,
latest_vitals: VitalSigns,
last_frame_time: Option<std::time::Instant>,
edge_vitals: Option<Esp32VitalsPacket>,
}
prev_person_count for active nodes
(seen within last 10 seconds).SensingUpdate.nodes.simulated_data_task) continues using global state.sensing_update) remains the same but the
nodes array now contains all active nodes, and estimated_persons reflects
the cross-node aggregate.| Nodes | Per-Node Memory | Total Overhead | Notes |
|---|---|---|---|
| 1 | ~50 KB | ~50 KB | Identical to current |
| 3 | ~50 KB | ~150 KB | Typical home setup |
| 10 | ~50 KB | ~500 KB | Small office |
| 50 | ~50 KB | ~2.5 MB | Building floor |
| 100 | ~50 KB | ~5 MB | Large deployment |
| 256 | ~50 KB | ~12.8 MB | Max (u8 node_id) |
Memory is dominated by frame_history (100 frames x ~500 bytes each = ~50 KB
per node). This scales linearly and fits comfortably in server memory even at
256 nodes.
The existing QEMU swarm infrastructure (ADR-062, scripts/qemu_swarm.py)
supports multi-node simulation with configurable topologies:
star: Central coordinator + sensor nodesmesh: Fully connected peer networkline: Sequential chainring: Circular topologyEach QEMU instance runs with a unique node_id via NVS provisioning. The
swarm health validator (scripts/swarm_health.py) checks per-node UART output.
Validation plan:
smooth_and_classify_node function duplicates some logic from global versionVitalSignDetector instances add CPU cost proportional to node count