diff --git a/CHANGELOG.md b/CHANGELOG.md index 05c22cc1..6a978520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Live trust path: sensing-server routes real frames through the governed `StreamingEngine` (parallel governed path with partial output gating).** Previously the live server ran only the *bare* `MultistaticFuser` (fused amplitudes, no trust control plane), while the privacy/provenance/witness engine (ADR-135..146) ran only on synthetic in-test frames — the gap called out in ADR-136 §8 and the beyond-SOTA system review. New `engine_bridge` module drives `StreamingEngine::process_cycle` from the server's live `NodeState` map (reusing the existing `NodeState → MultiBandCsiFrame` conversion), lazily wiring each node as a WorldGraph sensor and bounding belief growth via the retention cap; every *governed belief* carries evidence + model + calibration + privacy decision and a deterministic witness. **Honest scope:** the engine runs alongside (not instead of) the bare fusion path that feeds the live `SensingUpdate`. What its decision gates on the wire today: a cycle emitted at class `Restricted` (base mode or contradiction/mesh-risk demotion) suppresses the per-node raw amplitude vectors from the live publish — the same field mapping `wifi-densepose-bfld`'s privacy gate applies at `Restricted`; gating the remaining derived outputs (person count, classification, signal field) is tracked as a follow-up. Trust state is no longer write-only: the latest witness, effective privacy class, demotion flag, recalibration recommendation, and an engine-error counter are readable on `GET /api/v1/status`, and engine errors are counted + rate-limit logged instead of silently swallowed (`EngineBridge::observe_cycle`). Adds `wifi-densepose-engine/-worldgraph/-bfld/-geo` deps. Bridge tests cover witnessed belief with provenance, determinism, idempotent node registration, retention bound, privacy-mode propagation, trust-state recording, the error-counter path, and Restricted-class raw-output suppression. ### Fixed +- **Real HE20 CSI no longer silently dropped or replaced with simulated data (fixes #1009, #1004).** Two ingest bugs caused real ESP32-C6 HE20 frames to be discarded or never received — the exact "real data silently lost" failure class the project fights. Each fix is pinned by a test that fails on the old code. + - **#1009 §1b — HE20 baseline recorder trimmed 256 → 242 bins by sequential index (`wifi-densepose-signal/src/ruvsense/calibration.rs`).** ESP-IDF v5.5.2 delivers all 256 FFT bins for an HE20 frame; `CalibrationConfig::he20()` carried `num_active: 242`, so the recorder (which has no HE20 tone map — `extract_first_stream` takes the first `num_active` columns *sequentially*) kept bins 0..242 of the 256-bin grid. Those are the lower guard band + DC, **not** the 242 active tones, silently corrupting the empty-room baseline. Now `num_active: 256` records every delivered bin, staying aligned 1:1 with the live `deviation()` path. The exact-242 tone map deliberately stays only in `cir.rs` (`HE20_ACTIVE`), where the Φ sensing matrix genuinely needs it. Test `he20_records_all_256_bins_not_trimmed_to_242` asserts the finalized baseline covers all 256 bins (was 242). HE20 synthetic/bench fixtures updated to feed 256-bin frames (the real wire format). + - **#1009 §1a/§1c — already-fixed u8→u16 `n_subcarriers` truncation, now regression-pinned.** The ADR-018 wire format carries `n_subcarriers` as u16 LE at bytes 6–7. A 256-bin HE20 frame (byte6=0x00, byte7=0x01) read as a single byte decodes to **0 subcarriers** → every frame skipped (invisible until HE20: ESP32-S3's ≤192 bins fit in one byte). The CLI parser (`wifi-densepose-cli/calibrate.rs`) and the sensing-server template parser (`wifi-densepose-sensing-server` `parse_esp32_frame`) were already corrected to u16 under #1005/ADR-110; added regression tests (`parse_esp32_frame_he20_256_bins_not_truncated`, CLI `test_parse_csi_packet_he_su_256_bins`) that fail on the old single-byte read so the truncation cannot silently return. + - **#1004 — `--source auto` latched on `simulate` forever, never binding UDP :5005 (`wifi-densepose-sensing-server/src/main.rs`).** A one-shot boot probe resolved the source once; with no CSI flowing at boot (the normal firmware/server startup race) it served simulated poses for the whole process and ignored real CSI that arrived seconds later (the prior #937 fix hard-exited instead — equally wrong, the server could never pick up late-starting CSI). New `plan_source()` state machine: in `auto` mode **always bind the UDP receiver** and serve simulated data only until the first real frame, at which point `udp_receiver_task` promotes `source` → `esp32` (mirroring the existing `esp32 → esp32:offline` reversion in `effective_source()`); `simulated_data_task` self-suspends once promoted so it never clobbers live CSI. Explicit `--source simulated` stays a hard, UDP-free override for offline demos. 6 unit tests pin the resolution/promotion machine (`auto_with_no_boot_source_still_binds_udp_and_simulates`, etc.); the auto-binds-UDP assertion fails on the old behavior. - **`wifi-densepose-mat` standalone `--no-default-features` build (101 errors → 0).** `pub mod api` was unconditional while its only dependency, serde, is optional behind the `api` feature — so any build without default features failed with unresolved serde imports (masked in `--workspace` runs by feature unification). The `api` module and its `create_router`/`AppState` re-export are now `#[cfg(feature = "api")]`-gated (with docsrs annotations). All feature combos compile: bare `--no-default-features`, `--no-default-features --features api`, and full default (177 tests pass). - **WorldGraph no longer grows unboundedly under the live loop.** `StreamingEngine::process_cycle` appended one `SemanticState` belief per cycle with no eviction — ~1.7M nodes/day at 20 Hz (identified in `docs/research/ruview-beyond-sota/04-optimization-roadmap.md`). Added `WorldGraph::prune_semantic_states(max)` — deterministic eviction of the oldest beliefs by `(valid_from_unix_ms, id)`, structural nodes (rooms/zones/sensors/anchors/tracks/events) never eligible — and wired it into the engine after each belief append (`StreamingEngine::DEFAULT_SEMANTIC_RETENTION` = 7,200 ≈ 6 min at 20 Hz; tunable via `set_semantic_retention`). The WorldGraph holds *current* beliefs; durable history is the recorder's job, so no audit data is lost. 3 new tests (bounded growth end-to-end, oldest-only eviction, deterministic tie-break). - **ESP32 edge heart rate no longer stuck at ~45 BPM / dropping wildly — #987.** The on-device HR estimator (`edge_processing.c`, `0xC5110002`) reported ~45 BPM regardless of true heart rate (Apple-Watch ground truth 87 BPM read as ~45) and swung frame-to-frame. Two root causes: (1) a hardcoded `sample_rate = 10.0f` that became wrong after #985's self-ping raised the CSI callback rate to a variable ~13–19 Hz — BPM scales as `assumed/actual × true`, so 87 read ~45 and the reading swung as CSI yield fluctuated; (2) the zero-crossing estimator locked onto a breathing harmonic (a 0.25 Hz breathing fundamental puts its 3rd harmonic at ~0.74 Hz ≈ 44 BPM inside the HR band). Fix: measure the real sample rate from inter-frame timestamps (used for BPM conversion + biquad re-tuning on >15% drift); replace the HR zero-crossing with an autocorrelation estimator that rejects breathing harmonics (driven by a robust autocorr breathing period); median-13 smooth the output. Hardware A/B (fixed vs unmodified control board, both `edge_tier=2`): control pegged 40–49 BPM; fixed reaches the true 88–91 BPM (vs 87 GT) and holds a stable physiological value (spread 59→0 for a steady subject). Known limitation: heavy subject motion still degrades the estimate (motion gating is a follow-up). diff --git a/v2/crates/wifi-densepose-cli/src/calibrate.rs b/v2/crates/wifi-densepose-cli/src/calibrate.rs index d4c261ff..7baf1948 100644 --- a/v2/crates/wifi-densepose-cli/src/calibrate.rs +++ b/v2/crates/wifi-densepose-cli/src/calibrate.rs @@ -405,7 +405,9 @@ mod tests { #[test] fn test_tier_config_he20() { let cfg = tier_config("he20"); - assert_eq!(cfg.num_active, 242); + // Issue #1009 §1b: HE20 baseline records all 256 delivered bins + // (no tone map in the recorder), not the 242 active tones. + assert_eq!(cfg.num_active, 256); } #[test] diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 0c838e10..76c2943f 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -1483,6 +1483,65 @@ fn parse_esp32_frame(buf: &[u8]) -> Option { }) } +#[cfg(test)] +mod issue_1009_n_subcarriers_u16_tests { + //! Issue #1009 §1c — `parse_esp32_frame` must read `n_subcarriers` as a + //! u16 LE at bytes 6..7 (ADR-018 wire format), not a single byte at 6. + //! + //! An ESP32-C6 HE20 frame carries 256 subcarriers → byte 6 = 0x00, + //! byte 7 = 0x01. The pre-#1005 single-byte read decoded this as 0 + //! subcarriers, silently dropping every real HE20 frame. This was the same + //! truncation as the CLI parser (`wifi-densepose-cli` calibrate.rs); this + //! module pins that the sensing-server template stays u16-correct. + use super::*; + + /// Build an ADR-018 CSI frame (magic 0xC511_0001, 20-byte header). + fn build_csi_frame(n_subcarriers: u16) -> Vec { + let mut buf = vec![0u8; 20 + n_subcarriers as usize * 2]; + buf[0..4].copy_from_slice(&0xC511_0001u32.to_le_bytes()); + buf[4] = 7; // node_id + buf[5] = 1; // n_antennas + buf[6..8].copy_from_slice(&n_subcarriers.to_le_bytes()); // u16 LE + buf[8..12].copy_from_slice(&5180u32.to_le_bytes()); // freq_mhz (5 GHz HE) + buf[12..16].copy_from_slice(&42u32.to_le_bytes()); // sequence + buf[16] = (-40i8) as u8; // rssi + buf[17] = (-90i8) as u8; // noise_floor + buf[18] = 0; // ppdu_type + buf[19] = 0; + for k in 0..n_subcarriers as usize { + buf[20 + k * 2] = (5 + (k % 40) as i8) as u8; // i + buf[20 + k * 2 + 1] = (k % 30) as u8; // q + } + buf + } + + #[test] + fn parse_esp32_frame_he20_256_bins_not_truncated() { + // 256 = 0x0100 LE: byte6 = 0x00, byte7 = 0x01. A u8 read of byte 6 + // would see 0 subcarriers; a u16 read sees 256. + let buf = build_csi_frame(256); + assert_eq!(buf.len(), 532, "256-bin frame wire size = 20 + 256*2"); + let frame = parse_esp32_frame(&buf).expect("256-bin HE20 frame must parse"); + assert_eq!( + frame.n_subcarriers, 256, + "n_subcarriers must read as u16 (256), not the byte-6-only 0" + ); + assert_eq!(frame.amplitudes.len(), 256); + assert_eq!(frame.node_id, 7); + assert_eq!(frame.rssi, -40); + assert_eq!(frame.sequence, 42); + } + + #[test] + fn parse_esp32_frame_ht20_64_bins_still_parses() { + // Regression guard for the common single-byte (≤255) case. + let buf = build_csi_frame(64); + let frame = parse_esp32_frame(&buf).expect("64-bin HT20 frame must parse"); + assert_eq!(frame.n_subcarriers, 64); + assert_eq!(frame.amplitudes.len(), 64); + } +} + // ── Signal field generation ────────────────────────────────────────────────── /// Generate a signal field that reflects where motion and signal changes are occurring. @@ -2694,6 +2753,203 @@ async fn probe_esp32(port: u16) -> bool { } } +// ── Source resolution state machine (issue #1004) ──────────────────────────── + +/// What background tasks to start, derived from `--source` and the boot probes. +/// +/// Issue #1004: a one-shot startup probe latched `auto` to `simulate` forever +/// when no CSI happened to be flowing at boot (the normal case — the firmware +/// and the server race to come up). The UDP :5005 receiver was then never +/// bound, so real CSI arriving seconds later was silently ignored and the +/// server served simulated poses for the rest of the process. The UI looked +/// live; the data was fake. This is the exact "where's the real data?" failure +/// class the project fights. +/// +/// The robust resolution: in `auto` mode **always bind the UDP receiver** +/// regardless of the boot probe. If no real source is up yet, serve simulated +/// data *and* keep the UDP receiver listening; the receiver promotes +/// `source` → `esp32` the instant the first real frame lands (see +/// `udp_receiver_task`, which sets `s.source = "esp32"`), mirroring the inverse +/// `esp32 → esp32:offline` reversion already in `effective_source()`. +/// +/// Explicit `--source simulated` is a hard override for offline demos: it does +/// NOT bind UDP, so no promotion ever happens. +#[derive(Debug, Clone, PartialEq, Eq)] +struct SourcePlan { + /// The `AppStateInner.source` value to start with. + initial_source: String, + /// Bind the UDP :5005 receiver (and thus allow simulate→esp32 promotion). + bind_udp: bool, + /// Run the simulated-data generator (serves poses until a real frame arrives). + run_simulator: bool, + /// Run the Windows WiFi capture task. + run_wifi: bool, +} + +/// Pure decision function — fully unit-testable without binding sockets. +/// +/// `requested` is the normalized `--source` value. `esp32_detected` / +/// `wifi_detected` are the boot-probe results (only consulted in `auto` mode). +/// Returns `None` for an unknown source that names neither a real source nor a +/// simulate alias (the caller maps that to its own pass-through/exit policy). +fn plan_source(requested: &str, esp32_detected: bool, wifi_detected: bool) -> SourcePlan { + match requested { + "auto" => { + if esp32_detected { + // Real CSI already flowing — bind UDP, no simulator. + SourcePlan { + initial_source: "esp32".to_string(), + bind_udp: true, + run_simulator: false, + run_wifi: false, + } + } else if wifi_detected { + SourcePlan { + initial_source: "wifi".to_string(), + bind_udp: false, + run_simulator: false, + run_wifi: true, + } + } else { + // No real source *yet*. Serve simulated data, but ALSO bind UDP + // so the receiver can promote to esp32 when the first real + // frame arrives (issue #1004). Never latch on simulate. + SourcePlan { + initial_source: "simulated".to_string(), + bind_udp: true, + run_simulator: true, + run_wifi: false, + } + } + } + // Explicit overrides. "simulate" is a back-compat alias for "simulated". + "simulate" | "simulated" => SourcePlan { + initial_source: "simulated".to_string(), + bind_udp: false, // hard override: offline demo, no live promotion + run_simulator: true, + run_wifi: false, + }, + "esp32" => SourcePlan { + initial_source: "esp32".to_string(), + bind_udp: true, + run_simulator: false, + run_wifi: false, + }, + "wifi" => SourcePlan { + initial_source: "wifi".to_string(), + bind_udp: false, + run_simulator: false, + run_wifi: true, + }, + // Unknown source — preserve it verbatim, no tasks (caller's policy). + other => SourcePlan { + initial_source: other.to_string(), + bind_udp: false, + run_simulator: false, + run_wifi: false, + }, + } +} + +#[cfg(test)] +mod issue_1004_source_plan_tests { + //! Issue #1004 — `--source auto` must NOT latch on `simulate` forever. + //! + //! Old behavior: a one-shot boot probe resolved the source once. With no CSI + //! flowing at boot (the normal case), the server either latched on simulate + //! (never binding UDP :5005, so later real CSI was silently ignored) or + //! hard-exited (#937), never picking up CSI that started after launch. + //! + //! New behavior (`plan_source`): in `auto` the UDP receiver is ALWAYS bound, + //! simulated data is served only until the first real frame, then + //! `udp_receiver_task` promotes `source` → "esp32". These tests pin the + //! resolution/promotion state machine directly (no sockets bound). + use super::*; + + // FAILS ON OLD CODE: the old `auto`-with-no-source path bound no UDP + // receiver (it spawned only `simulated_data_task`, or exited). This asserts + // UDP IS bound even when the boot probe finds no source. + #[test] + fn auto_with_no_boot_source_still_binds_udp_and_simulates() { + let plan = plan_source("auto", false, false); + assert!(plan.bind_udp, "auto must bind UDP :5005 even with no boot source (#1004)"); + assert!(plan.run_simulator, "auto must serve simulated data until real CSI arrives"); + assert!(!plan.run_wifi); + assert_eq!(plan.initial_source, "simulated"); + } + + #[test] + fn auto_with_esp32_detected_binds_udp_no_simulator() { + let plan = plan_source("auto", true, false); + assert!(plan.bind_udp); + assert!(!plan.run_simulator, "real CSI present → no synthetic frames"); + assert_eq!(plan.initial_source, "esp32"); + } + + #[test] + fn auto_with_wifi_detected_runs_wifi_no_udp() { + let plan = plan_source("auto", false, true); + assert!(plan.run_wifi); + assert!(!plan.bind_udp); + assert!(!plan.run_simulator); + assert_eq!(plan.initial_source, "wifi"); + } + + // Explicit `--source simulated` is a hard offline override: it must NOT bind + // UDP (so it can never be promoted to live), distinguishing it from + // auto-mode simulate. + #[test] + fn explicit_simulated_is_offline_override_no_udp() { + for s in ["simulated", "simulate"] { + let plan = plan_source(s, false, false); + assert!(!plan.bind_udp, "{s}: explicit simulate must not bind UDP (offline demo)"); + assert!(plan.run_simulator); + assert_eq!(plan.initial_source, "simulated"); + } + } + + #[test] + fn explicit_esp32_binds_udp() { + let plan = plan_source("esp32", false, false); + assert!(plan.bind_udp); + assert!(!plan.run_simulator); + assert_eq!(plan.initial_source, "esp32"); + } + + // Promotion check: the runtime promotes by setting `AppStateInner.source` + // to "esp32" on the first real frame; `effective_source()` then reports it + // (and reverts to "esp32:offline" after a 5 s gap). This asserts the + // promotion direction the simulator/receiver rely on, without binding a + // socket — it exercises the same `source` field the UDP task writes. + #[test] + fn effective_source_promotes_from_simulated_to_esp32_on_real_frame() { + // Start as the auto/simulate plan would: source = "simulated". + let mut src = "simulated".to_string(); + // effective_source() logic for the simulate state: stays "simulated". + assert_eq!(promote_view(&src, None), "simulated"); + // First real frame arrives → udp_receiver_task sets source = "esp32". + src = "esp32".to_string(); + let fresh = Some(std::time::Duration::from_millis(10)); + assert_eq!(promote_view(&src, fresh), "esp32", "fresh esp32 frame ⇒ live"); + // After a >5 s gap it reverts to offline (inverse machinery, #1004). + let stale = Some(ESP32_OFFLINE_TIMEOUT + std::time::Duration::from_secs(1)); + assert_eq!(promote_view(&src, stale), "esp32:offline"); + } + + /// Mirror of `AppStateInner::effective_source` over just (source, age) so the + /// promotion/reversion logic is testable without constructing full state. + fn promote_view(source: &str, last_frame_age: Option) -> String { + if source == "esp32" { + if let Some(age) = last_frame_age { + if age > ESP32_OFFLINE_TIMEOUT { + return "esp32:offline".to_string(); + } + } + } + source.to_string() + } +} + // ── Simulated data generator ───────────────────────────────────────────────── fn generate_simulated_frame(tick: u64) -> Esp32Frame { @@ -5699,6 +5955,18 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) { interval.tick().await; let mut s = state.write().await; + + // Issue #1004: in `auto` mode this task runs alongside `udp_receiver_task`. + // Once a real frame promotes `source` → "esp32", stop emitting synthetic + // frames so we never clobber live CSI with simulated poses. (For an + // explicit `--source simulated` demo, `source` stays "simulated" and the + // simulator keeps running — that path never binds UDP, so it is never + // promoted.) The task stays alive so it can resume serving if the real + // source later ages out to "esp32:offline". + if s.effective_source() == "esp32" { + continue; + } + s.tick += 1; let tick = s.tick; @@ -6584,48 +6852,48 @@ async fn main() { info!(" UI path: {}", args.ui_path.display()); info!(" Source: {}", args.source); - // Auto-detect data source. + // Resolve the data source into a concrete task plan (issue #1004). // - // Issue #937 / sibling fix: previously `auto` silently fell back to the - // synthetic data source when no ESP32 or Windows WiFi was reachable, with - // only an `info!` log line as the signal. Downstream API consumers - // (`/api/v1/sensing/latest`, `/ws/sensing`) had no in-band way to know they - // were being served fake CSI tagged as production telemetry. That is the - // exact "where's the real data?" pattern external reviewers (#943, #934) - // cited as the most damaging evidence of the project misrepresenting its - // posture. Synthetic-data is now opt-in only — operators who want demo - // mode must explicitly set `--source simulated` or `CSI_SOURCE=simulated`. - let source = match args.source.as_str() { - "auto" => { - info!("Auto-detecting data source..."); - if probe_esp32(args.udp_port).await { - info!(" ESP32 CSI detected on UDP :{}", args.udp_port); - "esp32" - } else if probe_windows_wifi().await { - info!(" Windows WiFi detected"); - "wifi" - } else { - error!( - "No real CSI source detected. Auto-detection refuses to silently \ - fall back to synthetic data because that would expose downstream \ - consumers (/api/v1/sensing/latest, /ws/sensing) to fake telemetry \ - tagged as production. To run with synthetic data, set the source \ - explicitly: --source simulated (or CSI_SOURCE=simulated in Docker). \ - To use real hardware: provision an ESP32 to emit CSI on UDP :{} or \ - install the Windows WiFi capture driver. See \ - https://github.com/ruvnet/RuView/issues/937 for context.", - args.udp_port - ); - std::process::exit(78); // EX_CONFIG - } + // Issue #937 (prior fix): `auto` must never serve fake CSI *tagged as + // production telemetry*. We keep that guarantee — in the gap before real + // CSI arrives, `source` is the honest string "simulated" (downstream + // `/api/v1/sensing/latest`, `/ws/sensing` see `source: "simulated"`, not a + // production tag). What #937's hard-exit got wrong: at boot the firmware and + // server race, so CSI usually is NOT flowing during the 2 s probe. Exiting + // (or latching on simulate) meant the server could never pick up CSI that + // started seconds later. The robust resolution (see `plan_source`): in + // `auto` always bind the UDP :5005 receiver; serve simulated until the first + // real frame; then `udp_receiver_task` promotes `source` → "esp32". Explicit + // `--source simulated` stays a hard, UDP-free override for offline demos. + let normalized = if args.source == "simulate" { "simulated" } else { args.source.as_str() }; + let plan = if normalized == "auto" { + info!("Auto-detecting data source (UDP :{} bound either way)...", args.udp_port); + let esp32 = probe_esp32(args.udp_port).await; + let wifi = if esp32 { false } else { probe_windows_wifi().await }; + if esp32 { + info!(" ESP32 CSI detected on UDP :{}", args.udp_port); + } else if wifi { + info!(" Windows WiFi detected"); + } else { + warn!( + "No real CSI source at boot — serving SIMULATED data (tagged as \ + 'simulated', not production) while the UDP :{} receiver stays bound. \ + The server promotes to live the instant a real frame arrives (issue \ + #1004). For an offline demo with no live promotion, pass \ + --source simulated explicitly.", + args.udp_port + ); } - // "simulate" is a synonym for "simulated" (back-compat alias kept so - // existing operators who already opted in don't get broken by this fix). - "simulate" => "simulated", - other => other, + plan_source("auto", esp32, wifi) + } else { + plan_source(normalized, false, false) }; + let source: &str = plan.initial_source.as_str(); - info!("Data source: {source}"); + info!( + "Data source: {source} (udp_receiver={}, simulator={}, wifi={})", + plan.bind_udp, plan.run_simulator, plan.run_wifi + ); // Shared state // Vital sign sample rate derives from tick interval (e.g. 500ms tick => 2 Hz) @@ -6905,18 +7173,22 @@ async fn main() { data_dir: data_dir.clone(), })); - // Start background tasks based on source - match source { - "esp32" => { - tokio::spawn(udp_receiver_task(state.clone(), args.udp_port)); - tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms)); - } - "wifi" => { - tokio::spawn(windows_wifi_task(state.clone(), args.tick_ms)); - } - _ => { - tokio::spawn(simulated_data_task(state.clone(), args.tick_ms)); - } + // Start background tasks from the resolved plan (issue #1004). + // + // In `auto` mode with no boot source, `bind_udp` AND `run_simulator` are + // both true: the UDP receiver is bound so real CSI can promote the source, + // and the simulator serves poses in the meantime (it self-suspends once + // promoted — see `simulated_data_task`). Explicit `--source simulated` has + // `bind_udp = false`, so it serves simulated data only, with no live binding. + if plan.bind_udp { + tokio::spawn(udp_receiver_task(state.clone(), args.udp_port)); + tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms)); + } + if plan.run_wifi { + tokio::spawn(windows_wifi_task(state.clone(), args.tick_ms)); + } + if plan.run_simulator { + tokio::spawn(simulated_data_task(state.clone(), args.tick_ms)); } // ADR-050: Parse bind address once, use for all listeners diff --git a/v2/crates/wifi-densepose-signal/benches/calibration_bench.rs b/v2/crates/wifi-densepose-signal/benches/calibration_bench.rs index c2208830..f110965f 100644 --- a/v2/crates/wifi-densepose-signal/benches/calibration_bench.rs +++ b/v2/crates/wifi-densepose-signal/benches/calibration_bench.rs @@ -1,7 +1,7 @@ //! Criterion benchmarks for the empty-room baseline calibration module (ADR-135). //! //! Measures per-call throughput of CalibrationRecorder and BaselineCalibration -//! across HT20 (K=52), HT40 (K=114), HE20 (K=242), and HE40 (K=484). +//! across HT20 (K=52), HT40 (K=114), HE20 (K=256, all bins; #1009), and HE40 (K=484). //! //! Run (compile-only — no execution): //! cargo bench -p wifi-densepose-signal --no-default-features --bench calibration_bench --no-run @@ -63,7 +63,8 @@ fn tiers() -> Vec { vec![ TierSpec { label: "ht20", n_active: 52, bandwidth_mhz: 20, config: CalibrationConfig::ht20() }, TierSpec { label: "ht40", n_active: 114, bandwidth_mhz: 40, config: CalibrationConfig::ht40() }, - TierSpec { label: "he20", n_active: 242, bandwidth_mhz: 20, config: CalibrationConfig::he20() }, + // Issue #1009 §1b: HE20 records all 256 delivered bins (he20().num_active == 256). + TierSpec { label: "he20", n_active: 256, bandwidth_mhz: 20, config: CalibrationConfig::he20() }, TierSpec { label: "he40", n_active: 484, bandwidth_mhz: 40, config: CalibrationConfig::he40() }, ] } diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs index 8c38db28..146e251a 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs @@ -109,9 +109,26 @@ impl CalibrationConfig { pub fn ht40() -> Self { Self { tier: PhyTier::Ht40, num_subcarriers: 128, num_active: 114, min_frames: 600, max_phase_variance: 0.3 } } - /// HE20 defaults: 256 FFT, 242 active. + /// HE20 defaults: 256 FFT, **256 active** (record all delivered bins). + /// + /// Issue #1009: the ESP-IDF v5.5.2 driver delivers all 256 FFT bins on the + /// wire for an HE20 frame (242 data tones + pilots + guards + DC; n_subc = + /// 0x0100 LE, wire-verified on ESP32-C6). We set `num_active: 256` so the + /// recorder accumulates statistics over **every** delivered bin rather than + /// trimming to the first 242 columns. + /// + /// Why not 242? `CalibrationRecorder` has no HE20 tone map — `extract_first_stream` + /// takes the first `num_active` columns *sequentially*. With 242 it would + /// keep bins 0..242 of the 256-bin grid, which are NOT the 242 active tones + /// (they include the lower guard band and DC) — silently corrupting the + /// empty-room baseline. Recording all 256 bins keeps amplitude/phase stats + /// aligned 1:1 with the live `deviation()` path (which also sees 256 bins), + /// so guard/DC bins simply carry near-zero, stable statistics and never + /// generate false occupancy alarms. The exact-242 tone map lives only in + /// `cir.rs` (`HE20_ACTIVE`), where the Φ sensing matrix genuinely needs it; + /// the baseline recorder does not. pub fn he20() -> Self { - Self { tier: PhyTier::He20, num_subcarriers: 256, num_active: 242, min_frames: 600, max_phase_variance: 0.3 } + Self { tier: PhyTier::He20, num_subcarriers: 256, num_active: 256, min_frames: 600, max_phase_variance: 0.3 } } /// HE40 defaults: 512 FFT, 484 active. pub fn he40() -> Self { @@ -674,13 +691,38 @@ mod tests { let he20 = CalibrationConfig::he20(); assert_eq!(he20.num_subcarriers, 256); - assert_eq!(he20.num_active, 242); + // Issue #1009: HE20 records all 256 delivered bins (no tone map in the + // baseline recorder), not the 242 active tones — see he20() rationale. + assert_eq!(he20.num_active, 256); let he40 = CalibrationConfig::he40(); assert_eq!(he40.num_subcarriers, 512); assert_eq!(he40.num_active, 484); } + // Issue #1009 §1b: a real HE20 frame carries all 256 FFT bins. The recorder + // must accept it AND build the baseline over all 256 bins — not silently + // trim to the first 242 columns (which are guards/DC, not active tones). + // + // FAILS ON OLD CODE: with `he20().num_active == 242` the finalised baseline + // had only 242 subcarriers (256 → 242 sequential trim). This asserts 256. + #[test] + fn he20_records_all_256_bins_not_trimmed_to_242() { + let mut cfg = CalibrationConfig::he20(); + cfg.min_frames = 1; + let mut rec = CalibrationRecorder::new(cfg); + // Feed a 256-bin frame exactly as ESP-IDF v5.5.2 delivers it. + let frame = constant_frame(256, 1.0, 0.0); + rec.record(&frame).expect("256-bin HE20 frame must be accepted"); + let baseline = rec.finalize().expect("finalize after 1 frame (min_frames=1)"); + assert_eq!( + baseline.subcarriers.len(), + 256, + "HE20 baseline must cover all 256 delivered bins, not a 242-trim" + ); + assert_eq!(baseline.tier, PhyTier::He20); + } + // Additional: insufficient frames → error. #[test] fn finalize_requires_min_frames() { diff --git a/v2/crates/wifi-densepose-signal/tests/calibration_synthetic.rs b/v2/crates/wifi-densepose-signal/tests/calibration_synthetic.rs index 31d78e44..ac9494cc 100644 --- a/v2/crates/wifi-densepose-signal/tests/calibration_synthetic.rs +++ b/v2/crates/wifi-densepose-signal/tests/calibration_synthetic.rs @@ -67,7 +67,10 @@ fn ht40_spec() -> TierSpec { TierSpec { label: "HT40", n_active: 114, bandwidth_mhz: 40, config: CalibrationConfig::ht40() } } fn he20_spec() -> TierSpec { - TierSpec { label: "HE20", n_active: 242, bandwidth_mhz: 20, config: CalibrationConfig::he20() } + // Issue #1009 §1b: real HE20 frames carry all 256 FFT bins (242 data + + // pilots/guards/DC), and the recorder now records all 256 (he20().num_active + // == 256). Feed 256-bin frames to match the wire format. + TierSpec { label: "HE20", n_active: 256, bandwidth_mhz: 20, config: CalibrationConfig::he20() } } // ---------------------------------------------------------------------------