Compare commits

...

3 Commits

Author SHA1 Message Date
ruv 53b327e649 release: bump signal 0.3.4 / sensing-server 0.3.3 / cli 0.3.1 (fixes #1009, #1004)
HE20 calibration baseline fix (signal), sensing-server --source auto simulate-latch
fix (sensing-server), HE20 calibrate parser/asserts (cli). See PR #1038.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 16:55:27 -04:00
rUv ad3908bd9e Merge pull request #1038 from ruvnet/fix/issues-1009-1004-real-csi-ingest
fix: real CSI-ingest bugs — HE20 baseline corruption (#1009) + sensing-server simulate-latch (#1004)
2026-06-12 16:47:25 -04:00
ruv a27ee6f6cd fix(csi-ingest): real HE20 CSI no longer dropped or replaced with simulated data (#1009, #1004)
Two ingest bugs caused real ESP32-C6 HE20 CSI to be silently discarded or
never received — the "real data silently lost" failure class. 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.
ESP-IDF v5.5.2 delivers all 256 FFT bins for an HE20 frame, but
CalibrationConfig::he20() carried num_active: 242, so the recorder (no HE20
tone map — extract_first_stream takes the first num_active columns
sequentially) kept bins 0..242 = the lower guard band + DC, NOT the 242 active
tones, silently corrupting the empty-room baseline. Now num_active: 256 records
every delivered bin, aligned 1:1 with the live deviation() path. The exact-242
tone map stays only in cir.rs (HE20_ACTIVE), where the Phi sensing matrix needs
it. HE20 synthetic/bench fixtures updated to feed 256-bin frames.

#1009 §1a/§1c — u8->u16 n_subcarriers truncation, regression-pinned.
The ADR-018 wire format carries n_subcarriers as u16 LE at bytes 6-7; a 256-bin
HE20 frame (byte6=0x00) read as one byte decodes to 0 subcarriers -> every
frame skipped. The CLI parser and the sensing-server parse_esp32_frame were
already corrected to u16 under #1005/ADR-110; added regression tests 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.
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 arriving seconds later (the prior #937 fix
hard-exited instead — equally wrong). New plan_source() state machine: in auto
mode ALWAYS bind the UDP receiver and serve simulated only until the first real
frame, then udp_receiver_task promotes source -> esp32 (mirroring the existing
esp32 -> esp32:offline reversion). simulated_data_task self-suspends once
promoted. Explicit --source simulated stays a hard, UDP-free offline override.

Validation: 3-crate tests 1118 passed / 0 failed; workspace 3166 passed /
0 failed; Python proof VERDICT: PASS (bit-exact, unaffected). cir.rs untouched.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-12 16:37:55 -04:00
9 changed files with 384 additions and 60 deletions
+4
View File
@@ -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 67. 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 ~1319 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 4049 BPM; fixed reaches the true 8891 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).
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-cli"
version.workspace = true
version = "0.3.1"
edition.workspace = true
description = "CLI for WiFi-DensePose"
authors.workspace = true
@@ -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]
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-sensing-server"
version = "0.3.2"
version = "0.3.3"
edition.workspace = true
description = "Lightweight Axum server for WiFi sensing UI with RuVector signal processing"
license.workspace = true
@@ -1483,6 +1483,65 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
})
}
#[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<u8> {
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<std::time::Duration>) -> 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
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-signal"
version = "0.3.3"
version = "0.3.4"
edition.workspace = true
description = "WiFi CSI signal processing for DensePose estimation"
license.workspace = true
@@ -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<TierSpec> {
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() },
]
}
@@ -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() {
@@ -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() }
}
// ---------------------------------------------------------------------------