mirror of
https://github.com/ruvnet/RuView
synced 2026-06-13 10:53:20 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53b327e649 | |||
| ad3908bd9e | |||
| a27ee6f6cd |
@@ -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).
|
||||
|
||||
@@ -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,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() }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user