diff --git a/CHANGELOG.md b/CHANGELOG.md index a02bd8e563..2443f9f877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (−22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies. ### Fixed +- **Empty room reported "1 person" — the server's raw-CSI classifier overrode the node's calibrated presence.** With CSI capture working (~34 pps of raw `0xC511_0001` frames), the server's raw-CSI motion classifier (`raw_classify` → `smooth_and_classify` → adaptive model) runs per frame and, being **uncalibrated for the specific room**, reads ambient CSI variance (the node's own 50 Hz self-ping traffic, other WiFi, multipath) as `present_moving` — so an empty room the ESP32 correctly reported as empty rendered as occupied, flickering as the raw-CSI updates interleaved with the (correct) feature_state updates. The ESP32 already computes presence on-device against a 60 s empty-room baseline (ADR-081 `feature_state`, `0xC511_0006`), which reads empty=0 correctly. Fix: added `prefer_board_presence()` — when a **fresh** feature_state presence exists (received < 5 s ago; tracked via new `AppStateInner::last_feature_state_at`), it overrides the raw-CSI classifier's `presence`/`motion_level` on both the aggregate and per-node classification (confidence left as the server computed it). The node's calibrated presence now wins; server presence tracks the hardware instead of hallucinating. Pinned by `prefer_board_presence_tests` (3): board-empty overrides a server false-positive (+ confidence untouched), board present-moving/present-still win, and no-fresh-feature_state leaves the server classification intact. `wifi-densepose-sensing-server` bin tests 175 → 178, 0 failed. +- **Sensing server silently dropped a node's default upstream payload — ADR-081 `feature_state` (`0xC511_0006`) was never parsed.** `udp_receiver_task` decoded only raw CSI (`0x01`), edge vitals (`0x02`), fused vitals (`0x04`), and WASM output (`0x07`), but firmware `rv_feature_state.h` documents `feature_state` as **"the default upstream payload from a node ... replaces raw ADR-018 CSI as the primary stream."** A node emitting its default 60-byte compact state (presence/motion/respiration/heartbeat/anomaly/coherence) therefore produced **zero UI updates** — `/health` stuck at `tick:0`, banner OFFLINE — despite frames arriving on UDP :5005 (MEASURED on an ESP32-S3-DevKitC-1: ~5 pps `0xC511_0006` received, all dropped). Added `parse_esp32_feature_state()` which validates the magic + 60-byte length and maps the packet into the existing `Esp32VitalsPacket` (presence fails **closed** on the node's `RV_QFLAG_PRESENCE_VALID` bit AND `presence_score >= 0.5`; `ts_us → timestamp_ms`; `respiration_bpm`/`heartbeat_bpm` passthrough), then wired it as an `.or_else()` fallback after the vitals parser so it flows through the unchanged `SensingUpdate`/WebSocket path. **`presence_score`/`motion_score` are normalised defensively** (non-finite → 0, clamp to `[0,1]`): the firmware (`adaptive_controller.c`) copies a *raw unclamped* detector magnitude into these `0..1`-documented fields — MEASURED at 13.38 on real hardware, which surfaced as "1338%" confidence in the UI — so the server no longer trusts the node to stay in range (the presence/motion booleans still assert from the clamped score). Pinned by `feature_state_parser_tests` (4 tests): field extraction + presence mapping, fail-closed when the valid-flag is clear, wrong-magic rejection, short-buffer rejection. `wifi-densepose-sensing-server` bin tests 171 → 175, 0 failed. (Field note: on a display-less DevKitC the shipped v0.7.0 binary also mis-detects a phantom panel → CSI stays MGMT-only → 0 pps; a display-disabled build restores ~30 pps yield. That is a stale-release/build issue — `main` already carries the RuView#1000 touch-probe bail — not a code change here.) - **ADR-263/264/265 implemented — the RuView npm surface fixed end-to-end (`@ruvnet/ruview@0.2.0`, `@ruvnet/rvagent@0.2.0`, `@ruv/ruview-cli`).** Harness (ADR-263 O1–O9): `claim-check` now **fails closed** on empty input (CLI exit 2 + `empty_text` tool error); the MCP stdio server dispatches `tools/call` asynchronously over promise-based `spawn` — `ping` answers while a long `verify`/`calibrate` runs (pinned by a new e2e test that runs a 3 s fake proof and asserts sub-second ping); the two `optionalDependencies` are gone so a cold `npx` installs exactly 1 package (MEASURED: was 4 packages / 620 kB / 71 files, `npm i` in a clean prefix); child output is captured as bounded rolling tails (no more 1 MiB `maxBuffer` kills); `node_monitor` passes the port via `sys.argv` instead of splicing it into `python -c` source; the MCP `serverInfo.version` reads package.json; `.claude/skills/*/SKILL.md` are generated from `skills/*.md` by a `prepack` sync script (byte-equality pinned by test); `which()` is a memoized dep-free PATH scan; tools are underscore-canonical (`ruview_claim_check`, …) with the dotted names accepted as call-time aliases, plus `resources/list`/`prompts/list` stubs; the guardrail's `METRIC_TERMS` matching is precision-fixed (word-boundary `map`/`f1`/`auc`/`iou`, code-span + label scrubbing, quantitative-claims-only) — ADR-263/264/265 and both package READMEs now PASS `claim-check` while real untagged claims still flag. 30/30 tests (MEASURED, `node --test`). rvagent (ADR-264 O1–O9): `exports` fixed (types-first, the never-built `dist/index.cjs` `require` target removed — verified broken in the published 0.1.0 tarball); tarball is map-free (127,704 B unpacked / 46 files / 0 maps — MEASURED, `npm pack --dry-run`, down from 188 kB with 44 maps); the Streamable HTTP transport is **actually wired** behind `RVAGENT_HTTP_PORT` with one transport + one MCP server per session (`mcp-session-id` routing), a 1 MiB body cap (413), and a port-aware localhost origin gate — the "dual-transport" description is now true; tools renamed to underscore-canonical with dotted router aliases; ONE Zod validation gate per call with the advertised JSON Schema generated from the same Zod source (`zod-to-json-schema`); `train_count` closes its log fds (was leaking 2/job) and persists job records to `/.json` so `job_status` survives restarts, with bounded log-tail reads; `detectCogBinary` actually probes its candidate paths; version reads package.json; `@types/express` dropped, `@types/jest` aligned to jest 29; README rewritten to match reality (no phantom `stdio`/`http`/`policy grant` subcommands; unimplemented ADR-124 catalog tools labeled roadmap). 99/99 jest tests (MEASURED); stdio handshake + HTTP session flow + 403/400/404/413 gates smoke-tested live. CLI: bin renamed `ruview-cli` (the `ruview` bin belongs to `@ruvnet/ruview`, ADR-265 D4), version single-sourced. Distribution (ADR-265 D1–D4): new `npm-packages.yml` (3-package × Node 20/22 matrix: tests, version-literal grep gate, pack-content/size gate, tarball-install smoke test incl. the fail-closed claim-check and an ESM-import probe that would have caught the broken `require` export, README claim-check) and `ruview-npm-release.yml` (publish from CI only, `npm publish --provenance`); `ci.yml` NODE_VERSION 18→20. - **Multistatic fusion never ran on a mixed-mode ESP32 mesh — live bridge fed raw, un-canonicalized per-node CSI to the fuser (#1170).** `node_frame_from_state` (`multistatic_bridge.rs`) wrapped each node's **raw** amplitude vector (HT20 ≈ 64 bins, HT40 ≈ 128/192) into a struct *named* `CanonicalCsiFrame` without ever resampling, so `MultistaticFuser::fuse` tripped `DimensionMismatch` on every cycle, silently fell back to per-node sum/dedup, and spun `total_engine_errors` unbounded. Added `HardwareNormalizer::resample_to_canonical` (resample-only, **no z-score** — preserves the amplitude scale the person-score's `variance/mean²` relies on) and run every node frame through it onto the canonical 56-tone grid before fusion. Heterogeneous meshes now fuse instead of erroring. Pinned by `heterogeneous_node_counts_canonicalize_and_fuse` (mixed 64/192 → fuses), `resample_to_canonical_is_length_only_no_zscore`, and an updated `test_node_frame_conversion`; the pre-existing `engine_bridge::observe_cycle_counts_engine_errors` was retargeted to force a `TimestampMismatch` (its old 56-vs-30 setup now canonicalizes cleanly). `wifi-densepose-signal` 501 / `wifi-densepose-sensing-server` 677 tests, 0 failed. - **`csi_fps_ema` reported the CSI frame rate 40–840× too high under bursty UDP delivery (#1180).** `update_csi_fps_ema` only rejected deltas `≤ 0` or `≥ 1 s`, so a 36 µs intra-burst arrival delta yielded `1/dt ≈ 27 kHz` straight into the EMA — the metric measured server arrival jitter, not the node's ~40 fps production rate. Added a `MIN_PLAUSIBLE_CSI_DT_SEC = 0.005` floor (derived from the firmware's 50 fps `CSI_MIN_SEND_INTERVAL_US` ceiling, ×4 slack) and made `observe_csi_frame_arrival` keep its anchor across sub-floor bursts so the next genuine inter-frame gap measures true cadence. Pinned by `subms_burst_delta_rejected`, `burst_interleaved_with_nominal_stays_in_band`, and `observe_csi_frame_arrival_ignores_subms_bursts`. diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 38a543d325..33c845d3c2 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -1084,6 +1084,12 @@ struct AppStateInner { br_buffer: VecDeque, /// ADR-039: Latest edge vitals packet from ESP32. edge_vitals: Option, + /// When the last ADR-081 feature_state (0xC511_0006) presence was received. + /// The node computes presence on-chip against a 60 s empty-room baseline, so + /// its presence flag is authoritative; the server's raw-CSI classifier is + /// uncalibrated for the room and false-positives on ambient variance. Used + /// to prefer the node's presence when a fresh feature_state exists. + last_feature_state_at: Option, /// ADR-040: Latest WASM output packet from ESP32. latest_wasm_events: Option, // ── Model management fields ───────────────────────────────────────────── @@ -1262,6 +1268,74 @@ fn parse_esp32_vitals(buf: &[u8]) -> Option { }) } +// ── ADR-081: Feature State Packet (magic 0xC511_0006) ──────────────────────── +// +// This is the *default* upstream payload from a node (rv_feature_state.h): a +// compact 60-byte per-node sensing state that replaced the raw ADR-018 CSI +// stream as the primary wire format. The board computes presence / motion / +// respiration / heartbeat on-chip and ships this instead of raw CSI. We decode +// it into the existing Esp32VitalsPacket so it flows through the same +// SensingUpdate path as ADR-039 vitals (0xC511_0002). +// +// Wire layout (packed, little-endian): +// [0..4] magic 0xC511_0006 [4] node_id [5] mode +// [6..8] seq [8..16] ts_us (u64) +// [16..20] motion_score f32 [20..24] presence_score f32 +// [24..28] respiration_bpm f32 [28..32] respiration_conf f32 +// [32..36] heartbeat_bpm f32 [36..40] heartbeat_conf f32 +// [40..44] anomaly_score f32 [44..48] env_shift_score f32 +// [48..52] node_coherence f32 [52..54] quality_flags u16 +// [54..56] reserved [56..60] crc32 +fn parse_esp32_feature_state(buf: &[u8]) -> Option { + if buf.len() < 60 { + return None; + } + let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); + if magic != 0xC511_0006 { + return None; + } + // The node writes a *raw* detector magnitude into `presence_score` / + // `motion_score` even though rv_feature_state.h documents them as 0..1 + // (firmware adaptive_controller.c copies the unclamped observation). A + // NaN/Inf or a >1 value would otherwise surface as e.g. "1338%" confidence + // in the UI, so normalise defensively at this trust boundary: coerce + // non-finite to 0 and clamp to [0, 1]. The presence/motion *booleans* are + // derived from the clamped score, so a raw 13.38 still reads as present. + let f32_at = |o: usize| f32::from_le_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]); + let unit = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + + let node_id = buf[4]; + let ts_us = u64::from_le_bytes([ + buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15], + ]); + let motion_score = unit(f32_at(16)); + let presence_score = unit(f32_at(20)); + let respiration_bpm = f32_at(24); + let heartbeat_bpm = f32_at(32); + let quality_flags = u16::from_le_bytes([buf[52], buf[53]]); + + // RV_QFLAG_PRESENCE_VALID = bit0. Treat presence as asserted when the node + // flags it valid AND the 1 s presence score clears 0.5; motion uses the + // 100 ms motion score. + let presence_valid = (quality_flags & 0x01) != 0; + let presence = presence_valid && presence_score >= 0.5; + let motion = motion_score >= 0.5; + + Some(Esp32VitalsPacket { + node_id, + presence, + fall_detected: false, + motion, + breathing_rate_bpm: respiration_bpm as f64, + heartrate_bpm: heartbeat_bpm as f64, + rssi: 0, // feature_state carries no RSSI + n_persons: if presence { 1 } else { 0 }, + motion_energy: motion_score, + presence_score, + timestamp_ms: (ts_us / 1000) as u32, + }) +} + // ── ADR-040: WASM Output Packet (magic 0xC511_0007 — reassigned per #928) ───── /// Single WASM event (type + value). @@ -1405,6 +1479,148 @@ fn parse_edge_fused_vitals(buf: &[u8]) -> Option { }) } +#[cfg(test)] +mod feature_state_parser_tests { + //! ADR-081 feature_state (`0xC511_0006`) is the node's *default* upstream + //! payload (see firmware `rv_feature_state.h`). The server historically + //! parsed only CSI/vitals/fused/WASM and silently dropped this stream, so a + //! node emitting its default payload produced zero UI updates. These tests + //! guard the parser + its mapping into `Esp32VitalsPacket`. + use super::*; + + /// Build a 60-byte feature_state packet with the given field values. + fn build_feature_state( + node_id: u8, + motion: f32, + presence: f32, + resp: f32, + hb: f32, + quality_flags: u16, + ts_us: u64, + ) -> Vec { + let mut b = vec![0u8; 60]; + b[0..4].copy_from_slice(&0xC511_0006u32.to_le_bytes()); + b[4] = node_id; + b[5] = 0; // mode + b[6..8].copy_from_slice(&1u16.to_le_bytes()); // seq + b[8..16].copy_from_slice(&ts_us.to_le_bytes()); + b[16..20].copy_from_slice(&motion.to_le_bytes()); + b[20..24].copy_from_slice(&presence.to_le_bytes()); + b[24..28].copy_from_slice(&resp.to_le_bytes()); // respiration_bpm + b[28..32].copy_from_slice(&0.8f32.to_le_bytes()); // respiration_conf + b[32..36].copy_from_slice(&hb.to_le_bytes()); // heartbeat_bpm + b[36..40].copy_from_slice(&0.8f32.to_le_bytes()); // heartbeat_conf + b[40..44].copy_from_slice(&0.0f32.to_le_bytes()); // anomaly + b[44..48].copy_from_slice(&0.0f32.to_le_bytes()); // env_shift + b[48..52].copy_from_slice(&1.0f32.to_le_bytes()); // node_coherence + b[52..54].copy_from_slice(&quality_flags.to_le_bytes()); + b + } + + #[test] + fn parses_fields_and_maps_presence_when_valid() { + // PRESENCE_VALID (bit0) set, presence_score 0.9 → presence asserted. + let pkt = build_feature_state(7, 0.7, 0.9, 15.0, 60.0, 0x01, 12_345_000); + let v = parse_esp32_feature_state(&pkt).expect("0xC511_0006 must parse"); + assert_eq!(v.node_id, 7); + assert!(v.presence, "presence_valid && score>=0.5 → present"); + assert!(v.motion, "motion_score 0.7 >= 0.5 → moving"); + assert_eq!(v.n_persons, 1); + assert!((v.breathing_rate_bpm - 15.0).abs() < 1e-6); + assert!((v.heartrate_bpm - 60.0).abs() < 1e-6); + assert!((v.presence_score - 0.9).abs() < 1e-6); + assert_eq!(v.timestamp_ms, 12_345); // ts_us / 1000 + } + + #[test] + fn presence_false_when_valid_flag_clear() { + // High score but PRESENCE_VALID clear → must NOT assert presence + // (fail-closed on the node's own validity flag). + let pkt = build_feature_state(1, 0.0, 0.95, 0.0, 0.0, 0x00, 0); + let v = parse_esp32_feature_state(&pkt).expect("must parse"); + assert!(!v.presence); + assert_eq!(v.n_persons, 0); + } + + #[test] + fn clamps_raw_and_non_finite_scores_to_unit_range() { + // Firmware writes a *raw* unclamped detector magnitude into + // presence_score/motion_score (observed 13.38 on real hardware) despite + // the 0..1 contract. The parser must normalise so the UI never shows + // e.g. "1338%" confidence, while presence still asserts. + let pkt = build_feature_state(1, 5.0, 13.381, 15.0, 60.0, 0x01, 0); + let v = parse_esp32_feature_state(&pkt).expect("must parse"); + assert!(v.presence, "raw score >= 0.5 → present"); + assert!((v.presence_score - 1.0).abs() < 1e-6, "presence clamped to 1.0"); + assert!((v.motion_energy - 1.0).abs() < 1e-6, "motion clamped to 1.0"); + + // Non-finite → coerced to 0.0 (fail-closed, not NaN-poisoned). + let nan_pkt = build_feature_state(1, f32::NAN, f32::INFINITY, 0.0, 0.0, 0x01, 0); + let nv = parse_esp32_feature_state(&nan_pkt).expect("must parse"); + assert_eq!(nv.presence_score, 0.0); + assert_eq!(nv.motion_energy, 0.0); + assert!(!nv.presence, "non-finite score must not assert presence"); + } + + #[test] + fn rejects_wrong_magic() { + let mut pkt = build_feature_state(1, 0.0, 0.0, 0.0, 0.0, 0x01, 0); + pkt[0..4].copy_from_slice(&0xC511_0002u32.to_le_bytes()); // vitals magic + assert!(parse_esp32_feature_state(&pkt).is_none()); + } + + #[test] + fn rejects_short_buffer() { + let pkt = build_feature_state(1, 0.0, 0.0, 0.0, 0.0, 0x01, 0); + assert!(parse_esp32_feature_state(&pkt[..59]).is_none()); + } +} + +#[cfg(test)] +mod prefer_board_presence_tests { + //! Fix A: the node's on-chip feature_state presence (empty-room-calibrated + //! on-device) must override the server's uncalibrated raw-CSI classifier, + //! which false-positives on ambient CSI variance. + use super::*; + + fn cls(motion: &str, present: bool) -> ClassificationInfo { + ClassificationInfo { + motion_level: motion.to_string(), + presence: present, + confidence: 0.9, + } + } + + #[test] + fn board_empty_overrides_server_false_positive() { + // Server hallucinated presence on ambient variance; board says empty. + let mut c = cls("present_moving", true); + assert!(prefer_board_presence(&mut c, Some((false, false)))); + assert!(!c.presence); + assert_eq!(c.motion_level, "absent"); + assert!((c.confidence - 0.9).abs() < 1e-9, "confidence untouched"); + } + + #[test] + fn board_present_moving_and_still_win() { + let mut m = cls("absent", false); + prefer_board_presence(&mut m, Some((true, true))); + assert!(m.presence && m.motion_level == "present_moving"); + + let mut s = cls("absent", false); + prefer_board_presence(&mut s, Some((true, false))); + assert!(s.presence && s.motion_level == "present_still"); + } + + #[test] + fn no_fresh_feature_state_leaves_server_classification() { + let mut c = cls("present_still", true); + assert!(!prefer_board_presence(&mut c, None)); + assert!(c.presence); + assert_eq!(c.motion_level, "present_still"); + } +} + #[cfg(test)] mod issue_928_magic_collision_tests { //! Issue #928 — `0xC511_0004` was being parsed as WASM output, eating the @@ -2086,6 +2302,38 @@ fn extract_features_from_frame( ) } +/// Fix A: prefer a node's on-chip `feature_state` (ADR-081, 0xC511_0006) +/// presence over the server's raw-CSI motion classifier. +/// +/// The ESP32 computes presence against a 60 s empty-room baseline on-device, so +/// its flag is authoritative for the room. The server's `raw_classify` pipeline +/// is uncalibrated for the specific room and false-positives on ambient CSI +/// variance (the node's own 50 Hz self-ping traffic, other WiFi, multipath), so +/// an empty room the node reports as empty was being rendered "present". +/// +/// When `board` is `Some((presence, motion))`, overwrites `classification`'s +/// presence + motion_level and returns `true`. `None` (no fresh feature_state) +/// leaves the server classification untouched and returns `false`. Confidence is +/// deliberately left as the server pipeline computed it. +fn prefer_board_presence( + classification: &mut ClassificationInfo, + board: Option<(bool, bool)>, +) -> bool { + let Some((present, motion)) = board else { + return false; + }; + classification.presence = present; + classification.motion_level = if motion { + "present_moving" + } else if present { + "present_still" + } else { + "absent" + } + .to_string(); + true +} + /// Simple threshold classification (no smoothing) — used as the "raw" input. fn raw_classify(score: f64) -> String { if score > 0.25 { @@ -5432,8 +5680,14 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { loop { match socket.recv_from(&mut buf).await { Ok((len, src)) => { - // ADR-039: Try edge vitals packet first (magic 0xC511_0002). - if let Some(vitals) = parse_esp32_vitals(&buf[..len]) { + // ADR-039: Try edge vitals packet first (magic 0xC511_0002); + // fall back to the ADR-081 feature_state packet (magic + // 0xC511_0006), which is the node's *default* primary payload. + // Both decode into Esp32VitalsPacket and share the SensingUpdate + // path below. + if let Some(vitals) = parse_esp32_vitals(&buf[..len]) + .or_else(|| parse_esp32_feature_state(&buf[..len])) + { debug!( "ESP32 vitals from {src}: node={} br={:.1} hr={:.1} pres={}", vitals.node_id, @@ -5694,6 +5948,7 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { } s.latest_update = Some(update); s.edge_vitals = Some(vitals); + s.last_feature_state_at = Some(std::time::Instant::now()); continue; } @@ -5867,6 +6122,25 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { // to avoid unsafe raw pointer (review finding #2). let adaptive_model_clone = s.adaptive_model.clone(); + // Fix A: capture the node's on-chip feature_state (0xC511_0006) + // presence BEFORE borrowing node_states. The ESP32 computes + // presence against a 60 s empty-room baseline on-device, so it + // is authoritative; the server's raw-CSI motion classifier below + // is uncalibrated for the room and reads ambient CSI variance + // (self-ping traffic / WiFi / multipath) as motion. When a fresh + // feature_state exists, its presence overrides the classifier. + let board_presence: Option<(bool, bool)> = { + let fresh = s.last_feature_state_at.is_some_and(|t| { + std::time::Instant::now().duration_since(t) + < std::time::Duration::from_secs(5) + }); + if fresh { + s.edge_vitals.as_ref().map(|v| (v.presence, v.motion)) + } else { + None + } + }; + let ns = s.node_states.entry(node_id).or_insert_with(NodeState::new); // ADR-110 iter 19 — feed the per-node fps EMA from real // CSI arrivals. The helper sets `last_frame_time` as a @@ -5917,6 +6191,15 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { (conf * 0.7 + classification.confidence * 0.3).clamp(0.0, 1.0); } + // Fix A: the node's calibrated feature_state presence wins over + // the server's raw-CSI classifier. Override the room/aggregate + // classification (and this node's motion level) so an empty room + // the ESP32 reports as empty is not turned "present" by ambient + // CSI variance. Confidence is left from the server pipeline. + if prefer_board_presence(&mut classification, board_presence) { + ns.current_motion_level = classification.motion_level.clone(); + } + ns.rssi_history.push_back(features.mean_rssi); if ns.rssi_history.len() > 60 { ns.rssi_history.pop_front(); @@ -7548,6 +7831,7 @@ async fn main() { hr_buffer: VecDeque::with_capacity(8), br_buffer: VecDeque::with_capacity(8), edge_vitals: None, + last_feature_state_at: None, latest_wasm_events: None, // Model management discovered_models: initial_models,