mirror of
https://github.com/ruvnet/RuView
synced 2026-08-10 20:31:42 +00:00
fix(firmware): C6 IDF v5.5 guard + HE-LTF host ingest + WITNESS-LOG-110 B1 resolution (#1005) (#1011)
* fix(firmware): c6_sync_espnow IDF v5.5 send-callback guard + B1 HE-LTF resolution (#1005)
Espressif backported the esp_now_send_cb_t signature change to v5.5
(esp_now_send_info_t = wifi_tx_info_t there), so the #944 guard must be
ESP_IDF_VERSION >= VAL(5,5,0), not MAJOR >= 6.
Validated on this repo's hardware toolchain:
- WITHOUT fix, IDF v5.5.2 esp32c6 build fails with the reporter's exact
incompatible-pointer error at c6_sync_espnow.c:199 (reproduced)
- WITH fix, clean build on IDF v5.5.2 (esp32c6) AND IDF v5.4 (regression)
Docs: WITNESS-LOG-110 §B1 marked RESOLVED WITH MEASUREMENT (external,
@stuinfla, issue #1005): IDF v5.4 driver downconverts HE->HT; v5.5.2
delivers true HE-LTF (532B / 256 bins / 242 tones, PPDU 0x01 HE-SU).
ADR-110 capability table updated accordingly.
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs: WITNESS-LOG-110 §B1 — in-house HE-LTF replication on the original COM12 C6
84% of 1,525 frames at 532B/PPDU 0x01 (HE-SU) with IDF v5.5.2 + the #1005
guard fix, AP ruv.net 11ax 2.4GHz. Two independent rigs now confirm:
v5.4 downconverts, v5.5.2 delivers 242-tone HE20.
Co-Authored-By: claude-flow <ruv@ruv.net>
* fix(host): 256-bin HE-LTF ingest end-to-end + latent offset bugs (#1005)
Audit of every ADR-018 consumer against live C6 HE20 frames (532B/256-bin):
- sensing-server + CLI calibrate parsers read n_subcarriers from one byte
(256 decoded as 0) with stale seq/rssi offsets (rssi always 0 — latent,
pre-existing, confirmed vs firmware csi_collector.c). Fixed to the real
ADR-018 layout; n_subcarriers u8->u16; byte 18 surfaced as typed PpduType.
- sensing-server probe buffer 256B -> 2048B (532B datagram errored on Windows)
- per-node grid gate: lock densest (n_subcarriers, ppdu_type) grid, re-warm
on upgrade, skip sparser minority frames — HT-64 never mixes into an
HE-256 baseline window
- hardware parser: HE-aware bandwidth classification (256-FFT HE20 = 20MHz,
was Bw160); PpduType/Adr018Flags re-exported
- verbatim live frames (532B HE-SU, 148B HT) embedded as regression fixtures
- archive python parser: bandwidth heuristic mirror fix
Live-validated: calibrate --tier he20 consumed 600x 256-bin frames into an
ADR-135 He20 baseline (242 tones) skipping 94 HT frames; sensing-server
shows node 12 active with real RSSI (-40dBm). 765 tests green across the
three crates; workspace check clean; Python proof PASS.
Co-Authored-By: claude-flow <ruv@ruv.net>
* test(fuzz): esp_netif/ping_sock/ip_addr stubs — un-break ADR-061 fuzz build after #954
csi_collector.c gained esp_netif.h / ping/ping_sock.h / lwip/ip_addr.h
includes for the #954 gateway self-ping; the host-fuzz stub env lacked
them, breaking the fuzz build on main since 5789351b7. Stubs return
no-gateway so the self-ping path early-outs (compiles + links, never
exercised — matches the fuzz threat model which targets frame
serialization, not the network stack).
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
|
||||
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use wifi_densepose_hardware::PpduType;
|
||||
|
||||
use crate::adaptive_classifier;
|
||||
use crate::types::*;
|
||||
@@ -84,6 +85,18 @@ pub fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse an ADR-018 raw CSI frame (magic 0xC511_0001).
|
||||
///
|
||||
/// Header layout (authoritative: firmware `csi_collector.c` / ADR-018):
|
||||
/// magic u32 LE @0, node_id u8 @4, n_antennas u8 @5, n_subcarriers u16 LE
|
||||
/// @6-7, freq_mhz u32 LE @8-11, sequence u32 LE @12-15, rssi i8 @16,
|
||||
/// noise_floor i8 @17, PPDU type u8 @18 (ADR-110), flags u8 @19 (ADR-110),
|
||||
/// I/Q pairs from @20.
|
||||
///
|
||||
/// Until issue #1005 this function read `n_subcarriers` from byte 6 alone
|
||||
/// (an ESP32-C6 HE-SU frame's 256 = 0x0100 LE decoded as 0 — the frame
|
||||
/// parsed "successfully" with zero subcarriers) and read sequence/rssi/
|
||||
/// noise at stale offsets 10/14/15 (rssi landed on sequence bytes ⇒ 0).
|
||||
pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
if buf.len() < 20 {
|
||||
return None;
|
||||
@@ -95,16 +108,18 @@ pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
|
||||
let node_id = buf[4];
|
||||
let n_antennas = buf[5];
|
||||
let n_subcarriers = buf[6];
|
||||
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
|
||||
let sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
|
||||
let rssi_raw = buf[14] as i8;
|
||||
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]);
|
||||
let freq_mhz_u32 = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
|
||||
let freq_mhz = u16::try_from(freq_mhz_u32).unwrap_or(0);
|
||||
let sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
|
||||
let rssi_raw = buf[16] as i8;
|
||||
let rssi = if rssi_raw > 0 {
|
||||
rssi_raw.saturating_neg()
|
||||
} else {
|
||||
rssi_raw
|
||||
};
|
||||
let noise_floor = buf[15] as i8;
|
||||
let noise_floor = buf[17] as i8;
|
||||
let ppdu_type = PpduType::from_byte(buf[18]);
|
||||
|
||||
let iq_start = 20;
|
||||
let n_pairs = n_antennas as usize * n_subcarriers as usize;
|
||||
@@ -131,6 +146,7 @@ pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
sequence,
|
||||
rssi,
|
||||
noise_floor,
|
||||
ppdu_type,
|
||||
amplitudes,
|
||||
phases,
|
||||
})
|
||||
@@ -964,11 +980,12 @@ pub fn generate_simulated_frame(tick: u64) -> Esp32Frame {
|
||||
magic: 0xC511_0001,
|
||||
node_id: 1,
|
||||
n_antennas: 1,
|
||||
n_subcarriers: n_sub as u8,
|
||||
n_subcarriers: n_sub as u16,
|
||||
freq_mhz: 2437,
|
||||
sequence: tick as u32,
|
||||
rssi: (-40.0 + 5.0 * (t * 0.2).sin()) as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: PpduType::HtLegacy,
|
||||
amplitudes,
|
||||
phases,
|
||||
}
|
||||
@@ -981,3 +998,76 @@ pub fn chrono_timestamp() -> u64 {
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ── ADR-110 / issue #1005 tests: live ESP32-C6 HE-LTF frames ────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod adr110_tests {
|
||||
use super::*;
|
||||
use crate::types::NodeState;
|
||||
|
||||
/// Verbatim 532-byte HE-SU UDP payload captured live 2026-06-11 from an
|
||||
/// ESP32-C6 (node 12, IDF v5.5): 256 subcarrier bins, byte18=0x01.
|
||||
const HE_FRAME_HEX: &str = "010011c50c010001800900005a2d0000d8a9011000000000000000000000f70ef70ef50cf30bf209f108f006ef03ee02ee00eefdeffbeff8f0f7f1f4f2f3f4f1f5f0f7eef8edfaecfdecffeb01ea03ea05e908ea0aeb0deb0fec11ee13f015f216f318f519f71afa1bfd1bff1c021c051b071b0a1a0c190f1811161315161218101a0e1b0c1c091d071e041f0120ff20fc20f91ff71ff41ef11def1cec1be919e717e615e413e311e10edf0cde09dd06dc04dc01dcffdcfbdcf9ddf6def3dff0e0ede2eae4e8e6e6e8e4eae2ebe0eedef1dcf4dbf7dafad9fdd900d903d806d909d90cda0fdc12dc14dd17df1ae11ce31ee520e722e924ed25f127f328f629f929fd2900290329062809270c260e26122516061a00001c201c1f1a211722142411250e260c27082804280129fe29fb28f927f627f426f125ef23ec22ea20e81eea20e81e891b53a82951565d4ffafbfebe9abddb10222aa47b3b371fd2c0860cd4d86ea2f35faccd46b0b66f6ff0050f2da27d1c92f7f8e1017cb545afd3e3fe60db6f478dc85a33b3454cf6df9061194a0a0fc3e0eedf76f1d292cb25c8f541dfcc4109f9f1a34955520ad8ffa3694ac395cbf6c19073a4aefb1ebf47c76730458431805d9f18ff2e81955e8752b29757f66e289f72f8e35309a737547c040444cbda1a81d221d950037ec38fd9d1dd0f56c3dc707a7bbfe66ca5a97ab7cc17d68d38ba43a1806f91f5911a5967e2c9f7f07186";
|
||||
|
||||
/// Verbatim 148-byte HT payload from the same node seconds later:
|
||||
/// 64 bins, byte18=0x00.
|
||||
const HT_FRAME_HEX: &str = "010011c50c01400080090000662d0000b1a900100000000000000000fcfaf909f013f112f213f212f311f410f511f510f610f510f411f410f411f312f213f214f214f212f313f513f512f611f610f80ef90df90c0000010eff11fe13ff11fe1300000000ff01000001010002000200020204000301040103000400040002ff03ff03fe02fe02fe01fd00edfc03fa000000000000";
|
||||
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_he_su_frame_parses_with_256_subcarriers() {
|
||||
let buf = unhex(HE_FRAME_HEX);
|
||||
assert_eq!(buf.len(), 532);
|
||||
let f = parse_esp32_frame(&buf).expect("532-byte HE frame must parse");
|
||||
assert_eq!(f.node_id, 12);
|
||||
assert_eq!(f.n_subcarriers, 256);
|
||||
assert_eq!(f.amplitudes.len(), 256);
|
||||
assert_eq!(f.freq_mhz, 2432);
|
||||
assert_eq!(f.sequence, 11610);
|
||||
assert_eq!(f.rssi, -40);
|
||||
assert_eq!(f.noise_floor, -87);
|
||||
assert_eq!(f.ppdu_type, PpduType::HeSu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_ht_frame_parses_with_64_subcarriers() {
|
||||
let buf = unhex(HT_FRAME_HEX);
|
||||
assert_eq!(buf.len(), 148);
|
||||
let f = parse_esp32_frame(&buf).expect("148-byte HT frame must parse");
|
||||
assert_eq!(f.node_id, 12);
|
||||
assert_eq!(f.n_subcarriers, 64);
|
||||
assert_eq!(f.amplitudes.len(), 64);
|
||||
assert_eq!(f.rssi, -79);
|
||||
assert_eq!(f.ppdu_type, PpduType::HtLegacy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_gate_never_mixes_ht_and_he_windows() {
|
||||
let he = parse_esp32_frame(&unhex(HE_FRAME_HEX)).unwrap();
|
||||
let ht = parse_esp32_frame(&unhex(HT_FRAME_HEX)).unwrap();
|
||||
let mut ns = NodeState::new();
|
||||
|
||||
// First frame locks the grid.
|
||||
assert!(ns.accept_grid(ht.grid()));
|
||||
ns.frame_history.push_back(ht.amplitudes.clone());
|
||||
|
||||
// HE upgrade: accepted, denser grid wins, history re-keyed.
|
||||
assert!(ns.accept_grid(he.grid()));
|
||||
assert!(ns.frame_history.is_empty(), "upgrade must clear HT history");
|
||||
ns.frame_history.push_back(he.amplitudes.clone());
|
||||
|
||||
// Interleaved HT minority frames are rejected from the feature path.
|
||||
assert!(!ns.accept_grid(ht.grid()));
|
||||
assert_eq!(ns.frame_history.len(), 1, "HT frame must not touch window");
|
||||
|
||||
// Steady-state HE frames keep flowing.
|
||||
assert!(ns.accept_grid(he.grid()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,15 +226,28 @@ struct Esp32Frame {
|
||||
magic: u32,
|
||||
node_id: u8,
|
||||
n_antennas: u8,
|
||||
n_subcarriers: u8,
|
||||
/// u16 since ADR-110 / issue #1005: ESP32-C6 HE-SU frames carry 256
|
||||
/// subcarrier bins (242 active HE20 tones). HT frames stay ≤128.
|
||||
n_subcarriers: u16,
|
||||
freq_mhz: u16,
|
||||
sequence: u32,
|
||||
rssi: i8,
|
||||
noise_floor: i8,
|
||||
/// ADR-110 byte 18: PPDU type the CSI was sampled from. Pre-ADR-110
|
||||
/// firmware sends 0 ⇒ `PpduType::HtLegacy`.
|
||||
ppdu_type: wifi_densepose_hardware::PpduType,
|
||||
amplitudes: Vec<f64>,
|
||||
phases: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Esp32Frame {
|
||||
/// The `(n_subcarriers, ppdu_type)` symbol-grid identity of this frame.
|
||||
/// HT-LTF and HE-LTF grids are not bin-comparable (ADR-110 / #1005).
|
||||
fn grid(&self) -> (u16, wifi_densepose_hardware::PpduType) {
|
||||
(self.n_subcarriers, self.ppdu_type)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sensing update broadcast to WebSocket clients
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SensingUpdate {
|
||||
@@ -442,6 +455,12 @@ struct NodeState {
|
||||
/// Most recent novelty score in [0.0, 1.0] (0 = exact-match in bank,
|
||||
/// 1 = no overlap). Consumed by the model-wake gate downstream.
|
||||
pub(crate) last_novelty_score: Option<f32>,
|
||||
/// ADR-110 / issue #1005: the `(n_subcarriers, ppdu_type)` grid this
|
||||
/// node's rolling windows were built on. ESP32-C6 nodes interleave
|
||||
/// HE-SU 256-bin frames with HT 64-bin frames on one socket; mixing
|
||||
/// the two symbol grids in `frame_history` corrupts variance/baseline
|
||||
/// statistics. See [`NodeState::accept_grid`].
|
||||
active_grid: Option<(u16, wifi_densepose_hardware::PpduType)>,
|
||||
}
|
||||
|
||||
/// Default EMA alpha for temporal keypoint smoothing (RuVector Phase 2).
|
||||
@@ -647,6 +666,35 @@ impl NodeState {
|
||||
),
|
||||
),
|
||||
last_novelty_score: None,
|
||||
active_grid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR-110 / issue #1005 grid gate: decide whether a frame on `grid`
|
||||
/// may enter this node's feature path, and update `active_grid`.
|
||||
///
|
||||
/// Returns `true` to accept. Policy: lock onto the densest grid seen.
|
||||
/// On a grid *upgrade* (more subcarriers — e.g. the first HE-SU 256-bin
|
||||
/// frame after HT 64-bin history) the rolling amplitude history and
|
||||
/// motion baseline are cleared so HT and HE symbol grids are never
|
||||
/// mixed in one window. Sparser-grid frames (the ~16% HT minority an
|
||||
/// ESP32-C6 keeps emitting alongside HE) are rejected from the feature
|
||||
/// path; the caller still records the arrival for fps/liveness.
|
||||
fn accept_grid(&mut self, grid: (u16, wifi_densepose_hardware::PpduType)) -> bool {
|
||||
match self.active_grid {
|
||||
None => {
|
||||
self.active_grid = Some(grid);
|
||||
true
|
||||
}
|
||||
Some(active) if active == grid => true,
|
||||
Some((active_n, _)) if grid.0 > active_n => {
|
||||
self.active_grid = Some(grid);
|
||||
self.frame_history.clear();
|
||||
self.baseline_motion = 0.0;
|
||||
self.baseline_frames = 0;
|
||||
true
|
||||
}
|
||||
Some(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1374,19 +1422,25 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
// [17] noise_floor (i8)
|
||||
// [18..19] reserved
|
||||
// [20..] I/Q data
|
||||
// Issue #1005: until 2026-06 this code read n_subcarriers from byte 6
|
||||
// alone (an ESP32-C6 HE-SU frame's 256 = 0x0100 LE decoded as 0 — the
|
||||
// frame parsed with zero subcarriers) and read sequence/rssi/noise at
|
||||
// stale offsets 10/14/15. Offsets below match the comment (and firmware).
|
||||
let node_id = buf[4];
|
||||
let n_antennas = buf[5];
|
||||
let n_subcarriers = buf[6];
|
||||
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
|
||||
let sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
|
||||
let rssi_raw = buf[14] as i8;
|
||||
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]);
|
||||
let freq_mhz =
|
||||
u16::try_from(u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]])).unwrap_or(0);
|
||||
let sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
|
||||
let rssi_raw = buf[16] as i8;
|
||||
// Fix RSSI sign: ensure it's always negative (dBm convention).
|
||||
let rssi = if rssi_raw > 0 {
|
||||
rssi_raw.saturating_neg()
|
||||
} else {
|
||||
rssi_raw
|
||||
};
|
||||
let noise_floor = buf[15] as i8;
|
||||
let noise_floor = buf[17] as i8;
|
||||
let ppdu_type = wifi_densepose_hardware::PpduType::from_byte(buf[18]);
|
||||
|
||||
let iq_start = 20;
|
||||
let n_pairs = n_antennas as usize * n_subcarriers as usize;
|
||||
@@ -1415,6 +1469,7 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
sequence,
|
||||
rssi,
|
||||
noise_floor,
|
||||
ppdu_type,
|
||||
amplitudes,
|
||||
phases,
|
||||
})
|
||||
@@ -2296,11 +2351,12 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
||||
magic: 0xC511_0001,
|
||||
node_id: 0,
|
||||
n_antennas: 1,
|
||||
n_subcarriers: obs_count.min(255) as u8,
|
||||
n_subcarriers: obs_count.min(u16::MAX as usize) as u16,
|
||||
freq_mhz: 2437,
|
||||
sequence: seq,
|
||||
rssi: first_rssi.clamp(-128.0, 127.0) as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
||||
amplitudes: multi_ap_frame.amplitudes.clone(),
|
||||
phases: multi_ap_frame.phases.clone(),
|
||||
};
|
||||
@@ -2482,6 +2538,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
|
||||
sequence: seq,
|
||||
rssi: rssi_dbm as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
||||
amplitudes: vec![signal_pct],
|
||||
phases: vec![0.0],
|
||||
};
|
||||
@@ -2615,7 +2672,11 @@ async fn probe_esp32(port: u16) -> bool {
|
||||
let addr = format!("0.0.0.0:{port}");
|
||||
match UdpSocket::bind(&addr).await {
|
||||
Ok(sock) => {
|
||||
let mut buf = [0u8; 256];
|
||||
// 2048 covers the largest ADR-018 frame: an ESP32-C6 HE-SU
|
||||
// capture is 532 bytes (issue #1005); on Windows a too-small
|
||||
// recv buffer makes recv_from error on the oversized datagram,
|
||||
// which made this probe fail against HE-only streams.
|
||||
let mut buf = [0u8; 2048];
|
||||
match tokio::time::timeout(Duration::from_secs(2), sock.recv_from(&mut buf)).await {
|
||||
Ok(Ok((len, _))) => parse_esp32_frame(&buf[..len]).is_some(),
|
||||
_ => false,
|
||||
@@ -2644,11 +2705,12 @@ fn generate_simulated_frame(tick: u64) -> Esp32Frame {
|
||||
magic: 0xC511_0001,
|
||||
node_id: 1,
|
||||
n_antennas: 1,
|
||||
n_subcarriers: n_sub as u8,
|
||||
n_subcarriers: n_sub as u16,
|
||||
freq_mhz: 2437,
|
||||
sequence: tick as u32,
|
||||
rssi: (-40.0 + 5.0 * (t * 0.2).sin()) as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
||||
amplitudes,
|
||||
phases,
|
||||
}
|
||||
@@ -5231,6 +5293,34 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
s.source = "esp32".to_string();
|
||||
s.last_esp32_frame = Some(std::time::Instant::now());
|
||||
|
||||
// ── ADR-110 / issue #1005: per-node subcarrier-grid gate ──
|
||||
// ESP32-C6 nodes interleave HE-SU 256-bin frames (~84%)
|
||||
// with HT 64-bin frames on the same socket. HT-LTF and
|
||||
// HE-LTF symbol grids are not bin-comparable, so a frame
|
||||
// on a different grid than the node's rolling window must
|
||||
// not enter the feature path. Policy (NodeState::accept_grid):
|
||||
// lock onto the densest grid seen, clear+re-warm on
|
||||
// upgrade, skip sparser-grid frames (arrival still
|
||||
// recorded for fps/liveness).
|
||||
let grid_accepted = s
|
||||
.node_states
|
||||
.entry(frame.node_id)
|
||||
.or_insert_with(NodeState::new)
|
||||
.accept_grid(frame.grid());
|
||||
if !grid_accepted {
|
||||
debug!(
|
||||
"node {}: skipping {}-subcarrier {:?} frame (active grid {:?})",
|
||||
frame.node_id,
|
||||
frame.n_subcarriers,
|
||||
frame.ppdu_type,
|
||||
s.node_states.get(&frame.node_id).and_then(|ns| ns.active_grid),
|
||||
);
|
||||
if let Some(ns) = s.node_states.get_mut(&frame.node_id) {
|
||||
ns.observe_csi_frame_arrival(std::time::Instant::now());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Also maintain global frame_history for backward compat
|
||||
// (simulation path, REST endpoints, etc.).
|
||||
s.frame_history.push_back(frame.amplitudes.clone());
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::rvf_container::RvfContainerInfo;
|
||||
use crate::rvf_pipeline::ProgressiveLoader;
|
||||
use crate::vital_signs::{VitalSignDetector, VitalSigns};
|
||||
|
||||
use wifi_densepose_hardware::PpduType;
|
||||
use wifi_densepose_signal::ruvsense::field_model::FieldModel;
|
||||
use wifi_densepose_signal::ruvsense::longitudinal::{EmbeddingEntry, EmbeddingHistory};
|
||||
use wifi_densepose_signal::ruvsense::multistatic::MultistaticFuser;
|
||||
@@ -84,15 +85,33 @@ pub struct Esp32Frame {
|
||||
pub magic: u32,
|
||||
pub node_id: u8,
|
||||
pub n_antennas: u8,
|
||||
pub n_subcarriers: u8,
|
||||
/// Subcarrier bin count. u16 since ADR-110: ESP32-C6 HE-LTF frames carry
|
||||
/// 256 bins (242 active HE20 tones) — issue #1005. HT frames stay ≤128.
|
||||
pub n_subcarriers: u16,
|
||||
pub freq_mhz: u16,
|
||||
pub sequence: u32,
|
||||
pub rssi: i8,
|
||||
pub noise_floor: i8,
|
||||
/// ADR-110 byte 18: PPDU type the CSI was sampled from (HT-LTF vs
|
||||
/// HE-LTF symbol grids are NOT comparable bin-for-bin). Pre-ADR-110
|
||||
/// firmware sends 0 ⇒ `PpduType::HtLegacy`.
|
||||
pub ppdu_type: PpduType,
|
||||
pub amplitudes: Vec<f64>,
|
||||
pub phases: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Esp32Frame {
|
||||
/// The (subcarrier-count, PPDU-type) pair identifying which symbol grid
|
||||
/// this frame was sampled on. Frames from different grids must never be
|
||||
/// mixed in one rolling baseline window (ADR-110 / issue #1005).
|
||||
pub fn grid(&self) -> CsiGrid {
|
||||
(self.n_subcarriers, self.ppdu_type)
|
||||
}
|
||||
}
|
||||
|
||||
/// Subcarrier-grid identity: `(n_subcarriers, ppdu_type)`.
|
||||
pub type CsiGrid = (u16, PpduType);
|
||||
|
||||
// ── Sensing Update ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Sensing update broadcast to WebSocket clients
|
||||
@@ -281,6 +300,14 @@ pub struct NodeState {
|
||||
/// `None` until the first `update_novelty` call. Consumed by the
|
||||
/// model-wake gate downstream (low novelty → skip CNN, save energy).
|
||||
pub last_novelty_score: Option<f32>,
|
||||
/// ADR-110 / issue #1005: the `(n_subcarriers, ppdu_type)` grid this
|
||||
/// node's rolling windows were built on. ESP32-C6 nodes interleave
|
||||
/// HE-SU 256-bin frames with HT 64-bin frames on one socket; mixing
|
||||
/// the two symbol grids in `frame_history` corrupts variance/baseline
|
||||
/// statistics. Policy: lock onto the densest grid seen; frames on a
|
||||
/// sparser grid are counted as arrivals but skipped by the feature
|
||||
/// path; a grid upgrade clears the history and re-warms the baseline.
|
||||
pub active_grid: Option<CsiGrid>,
|
||||
}
|
||||
|
||||
impl Default for NodeState {
|
||||
@@ -322,6 +349,35 @@ impl NodeState {
|
||||
NOVELTY_SKETCH_VERSION,
|
||||
)),
|
||||
last_novelty_score: None,
|
||||
active_grid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR-110 / issue #1005 grid gate: decide whether a frame on `grid`
|
||||
/// may enter this node's feature path, and update `active_grid`.
|
||||
///
|
||||
/// Returns `true` to accept. On a grid *upgrade* (more subcarriers than
|
||||
/// the current grid — e.g. first HE-SU 256-bin frame after HT 64-bin
|
||||
/// history) the rolling amplitude history and motion baseline are
|
||||
/// cleared so HT and HE symbol grids are never mixed in one window.
|
||||
/// Sparser-grid frames (the ~16% HT minority a C6 keeps emitting) are
|
||||
/// rejected from the feature path.
|
||||
pub fn accept_grid(&mut self, grid: CsiGrid) -> bool {
|
||||
match self.active_grid {
|
||||
None => {
|
||||
self.active_grid = Some(grid);
|
||||
true
|
||||
}
|
||||
Some(active) if active == grid => true,
|
||||
Some((active_n, _)) if grid.0 > active_n => {
|
||||
// Denser grid wins: re-key the window and re-warm baselines.
|
||||
self.active_grid = Some(grid);
|
||||
self.frame_history.clear();
|
||||
self.baseline_motion = 0.0;
|
||||
self.baseline_frames = 0;
|
||||
true
|
||||
}
|
||||
Some(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user