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>
This commit is contained in:
ruv
2026-06-11 09:46:12 -04:00
parent d347d44030
commit 25cf88f18e
9 changed files with 463 additions and 73 deletions
+7 -3
View File
@@ -221,11 +221,15 @@ class ESP32BinaryParser:
snr = float(rssi - noise_floor)
frequency = float(freq_mhz) * 1e6
bandwidth = 20e6 # default; could infer from n_subcarriers
if n_subcarriers <= 56:
# Bandwidth inference (issue #1005): HE-LTF uses a 4x denser tone
# grid than HT-LTF on the same channel width — an HE-SU frame with
# 256 bins (242 active HE20 tones) is a *20 MHz* capture, not 160.
if ppdu_byte in (1, 2, 3): # HE-SU / HE-MU / HE-TB
bandwidth = 40e6 if (flags_byte & 0x01) or n_subcarriers > 256 else 20e6
elif n_subcarriers <= 64: # ESP32 HT20 delivers the full 64-bin FFT
bandwidth = 20e6
elif n_subcarriers <= 114:
elif n_subcarriers <= 128:
bandwidth = 40e6
elif n_subcarriers <= 242:
bandwidth = 80e6
+59 -24
View File
@@ -8,22 +8,24 @@
//!
//! # Wire format parsed here (option b — local parser, no cross-crate dep)
//!
//! Authoritative layout: firmware `csi_collector.c` (ADR-018 + ADR-110).
//!
//! Offset Size Field
//! ────── ──── ─────────────────────────────────────────────────────────────
//! 0 4 Magic: 0xC511_0001 (LE u32)
//! 4 1 node_id (u8)
//! 5 1 n_antennas (u8)
//! 6 1 n_subcarriers (u8)
//! 7 1 (reserved)
//! 8 2 freq_mhz (LE u16)
//! 10 4 sequence (LE u32)
//! 14 1 rssi (i8)
//! 15 1 noise_floor (i8)
//! 16 4 (reserved / padding)
//! 6 2 n_subcarriers (LE u16 — 256 for ESP32-C6 HE-SU frames, #1005)
//! 8 4 freq_mhz (LE u32)
//! 12 4 sequence (LE u32)
//! 16 1 rssi (i8)
//! 17 1 noise_floor (i8)
//! 18 1 PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB)
//! 19 1 flags (ADR-110: bit0 bw40, bit4 time-sync valid)
//! 20 2 × n_antennas × n_subcarriers IQ pairs: i_val (i8), q_val (i8)
//!
//! This parser mirrors `parse_esp32_frame` in
//! `wifi-densepose-sensing-server/src/csi.rs` exactly (same magic, same layout).
//! `wifi-densepose-sensing-server/src/csi.rs` (same magic, same layout).
use anyhow::{bail, Result};
use clap::Args;
@@ -261,11 +263,15 @@ pub(crate) fn parse_csi_packet(buf: &[u8], tier: &str) -> Option<CsiFrame> {
let node_id = buf[4];
let n_antennas = buf[5] as usize;
let n_subcarriers = buf[6] as usize;
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 = buf[14] as i8;
let noise_floor = buf[15] as i8;
// u16 since ADR-110 / #1005: ESP32-C6 HE-SU frames carry 256 bins
// (the old single-byte read decoded 256 = 0x0100 LE as 0 subcarriers).
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]) as usize;
let freq_mhz = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
let freq_mhz = u16::try_from(freq_mhz).unwrap_or(0);
let _sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
let rssi = buf[16] as i8;
let noise_floor = buf[17] as i8;
let _ppdu_type = buf[18]; // ADR-110; baseline tier gating is by count
let n_pairs = n_antennas * n_subcarriers;
let iq_start = 20usize;
@@ -414,24 +420,53 @@ mod tests {
assert!(parse_csi_packet(&buf, "ht20").is_none());
}
/// Build an ADR-018 frame (correct firmware layout, ADR-110 bytes 18-19).
fn build_frame(n_subcarriers: u16, ppdu: u8) -> 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] = 12; // node_id
buf[5] = 1; // n_antennas
buf[6..8].copy_from_slice(&n_subcarriers.to_le_bytes());
buf[8..12].copy_from_slice(&2432u32.to_le_bytes()); // freq_mhz
buf[12..16].copy_from_slice(&11610u32.to_le_bytes()); // sequence
buf[16] = (-40i8) as u8; // rssi
buf[17] = (-87i8) as u8; // noise floor
buf[18] = ppdu;
buf[19] = 0x10; // time-sync valid
for k in 0..n_subcarriers as usize {
buf[20 + k * 2] = (10 + (k % 100) as i8) as u8;
buf[20 + k * 2 + 1] = (k % 50) as u8;
}
buf
}
#[test]
fn test_parse_csi_packet_valid() {
let mut buf = vec![0u8; 24]; // 20-byte header + 2 IQ pairs (1 antenna, 2 subcarriers)
// Magic 0xC511_0001 LE
buf[0] = 0x01; buf[1] = 0x00; buf[2] = 0x11; buf[3] = 0xC5;
buf[5] = 1; // n_antennas
buf[6] = 2; // n_subcarriers
// freq_mhz = 2437 (channel 6)
buf[8] = 0x85; buf[9] = 0x09;
// IQ pairs at offset 20: (10, 20), (5, 15)
buf[20] = 10i8 as u8; buf[21] = 20i8 as u8;
buf[22] = (-5i8) as u8; buf[23] = 15i8 as u8;
let buf = build_frame(2, 0);
let frame = parse_csi_packet(&buf, "ht20");
assert!(frame.is_some());
let f = frame.unwrap();
assert_eq!(f.num_spatial_streams(), 1);
assert_eq!(f.num_subcarriers(), 2);
assert_eq!(f.metadata.rssi_dbm, -40);
assert_eq!(f.metadata.noise_floor_dbm, -87);
}
#[test]
fn test_parse_csi_packet_he_su_256_bins() {
// ESP32-C6 HE-SU frame (issue #1005): n_subcarriers = 256 = 0x0100 LE.
// The pre-#1005 single-byte read decoded this as 0 subcarriers.
let buf = build_frame(256, 1);
assert_eq!(buf.len(), 532); // matches the live wire size
let f = parse_csi_packet(&buf, "he20").expect("256-bin HE frame must parse");
assert_eq!(f.num_subcarriers(), 256);
assert_eq!(f.metadata.rssi_dbm, -40);
// A 256-bin frame is accepted by the he20 recorder (num_subcarriers
// tier total) and rejected by ht20 (52/64) — no HT/HE mixing.
let mut he = wifi_densepose_signal::CalibrationRecorder::new(tier_config("he20"));
assert!(he.record(&f).is_ok());
let mut ht = wifi_densepose_signal::CalibrationRecorder::new(tier_config("ht20"));
assert!(ht.record(&f).is_err());
}
#[test]
@@ -16,7 +16,8 @@
//! 12 4 Sequence number (LE u32)
//! 16 1 RSSI (i8)
//! 17 1 Noise floor (i8)
//! 18 2 Reserved
//! 18 1 PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB)
//! 19 1 Flags (ADR-110: bit0 bw40, bit2 STBC, bit3 LDPC, bit4 15.4-sync)
//! 20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
//! ```
//!
@@ -240,12 +241,31 @@ impl Esp32CsiParser {
}
}
// Determine bandwidth from subcarrier count
let bandwidth = match n_subcarriers {
0..=56 => Bandwidth::Bw20,
57..=114 => Bandwidth::Bw40,
115..=242 => Bandwidth::Bw80,
_ => Bandwidth::Bw160,
// Determine bandwidth from PPDU type + subcarrier count (ADR-110).
//
// HE-LTF uses a 4x denser tone grid than HT-LTF on the same channel
// width: HE20 = 256-FFT (242 active tones), HE40 = 512-FFT (484
// active). So a 256-bin frame on an HE PPDU is *20 MHz*, not 160.
// For HE frames the firmware also writes the bandwidth into byte 19
// bit 0 (see Adr018Flags::bw40) — prefer that when set.
//
// HT/legacy keeps the count heuristic, with 64 included in the 20 MHz
// bucket: ESP32 HT20 CSI delivers the full 64-bin FFT grid (live
// capture evidence: 148-byte frames = 64 subcarriers on a 20 MHz
// channel, issue #1005).
let bandwidth = if ppdu_type.is_he() {
if adr018_flags.bw40 || n_subcarriers > 256 {
Bandwidth::Bw40
} else {
Bandwidth::Bw20
}
} else {
match n_subcarriers {
0..=64 => Bandwidth::Bw20,
65..=128 => Bandwidth::Bw40,
129..=242 => Bandwidth::Bw80,
_ => Bandwidth::Bw160,
}
};
let frame = CsiFrame {
+3 -1
View File
@@ -49,7 +49,9 @@ pub mod sync_packet;
pub mod radio_ops;
pub use bridge::CsiData;
pub use csi_frame::{AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, SubcarrierData};
pub use csi_frame::{
Adr018Flags, AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, PpduType, SubcarrierData,
};
pub use error::ParseError;
pub use esp32_parser::{
ruview_sibling_packet_name, Esp32CsiParser, ESP32_CSI_MAGIC, RUVIEW_COMPRESSED_CSI_MAGIC,
@@ -0,0 +1,90 @@
//! ADR-110 / issue #1005: real ESP32-C6 HE-LTF CSI frames captured live.
//!
//! Both fixtures below are verbatim UDP payloads captured on 2026-06-11 from
//! an ESP32-C6 (node_id 12, IDF v5.5 build) streaming to UDP :5005 — the
//! same node, same link, seconds apart. The 532-byte frame is an HE-SU
//! capture (256 subcarrier bins = 242 active HE20 tones); the 148-byte frame
//! is the HT fallback grid (64 bins) the same firmware emits for non-HE
//! traffic. They are the canonical regression fixtures for the non-fixed
//! subcarrier count introduced by HE-LTF.
use wifi_densepose_hardware::{Bandwidth, Esp32CsiParser, PpduType};
/// 532-byte HE-SU frame: header + 256 subcarrier I/Q pairs.
/// magic=0xC5110001 node=12 ant=1 nsub=256 freq=2432 seq=11610
/// rssi=-40 noise=-87 byte18=0x01 (HE-SU) byte19=0x10 (15.4-sync valid)
const HE_FRAME_HEX: &str = "010011c50c010001800900005a2d0000d8a9011000000000000000000000f70ef70ef50cf30bf209f108f006ef03ee02ee00eefdeffbeff8f0f7f1f4f2f3f4f1f5f0f7eef8edfaecfdecffeb01ea03ea05e908ea0aeb0deb0fec11ee13f015f216f318f519f71afa1bfd1bff1c021c051b071b0a1a0c190f1811161315161218101a0e1b0c1c091d071e041f0120ff20fc20f91ff71ff41ef11def1cec1be919e717e615e413e311e10edf0cde09dd06dc04dc01dcffdcfbdcf9ddf6def3dff0e0ede2eae4e8e6e6e8e4eae2ebe0eedef1dcf4dbf7dafad9fdd900d903d806d909d90cda0fdc12dc14dd17df1ae11ce31ee520e722e924ed25f127f328f629f929fd2900290329062809270c260e26122516061a00001c201c1f1a211722142411250e260c27082804280129fe29fb28f927f627f426f125ef23ec22ea20e81eea20e81e891b53a82951565d4ffafbfebe9abddb10222aa47b3b371fd2c0860cd4d86ea2f35faccd46b0b66f6ff0050f2da27d1c92f7f8e1017cb545afd3e3fe60db6f478dc85a33b3454cf6df9061194a0a0fc3e0eedf76f1d292cb25c8f541dfcc4109f9f1a34955520ad8ffa3694ac395cbf6c19073a4aefb1ebf47c76730458431805d9f18ff2e81955e8752b29757f66e289f72f8e35309a737547c040444cbda1a81d221d950037ec38fd9d1dd0f56c3dc707a7bbfe66ca5a97ab7cc17d68d38ba43a1806f91f5911a5967e2c9f7f07186";
/// 148-byte HT frame from the same node: header + 64 subcarrier I/Q pairs.
/// magic=0xC5110001 node=12 ant=1 nsub=64 freq=2432 seq=11622
/// rssi=-79 noise=-87 byte18=0x00 (HT/legacy) byte19=0x10
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_532_bytes_parses_with_256_subcarriers() {
let data = unhex(HE_FRAME_HEX);
assert_eq!(data.len(), 532);
let (frame, consumed) = Esp32CsiParser::parse_frame(&data).expect("HE frame must parse");
assert_eq!(consumed, 532);
assert_eq!(frame.metadata.node_id, 12);
assert_eq!(frame.metadata.n_antennas, 1);
assert_eq!(frame.metadata.n_subcarriers, 256);
assert_eq!(frame.subcarrier_count(), 256);
assert_eq!(frame.metadata.channel_freq_mhz, 2432);
assert_eq!(frame.metadata.sequence, 11610);
assert_eq!(frame.metadata.rssi_dbm, -40);
assert_eq!(frame.metadata.noise_floor_dbm, -87);
// ADR-110 byte 18: HE-SU PPDU. Byte 19 bit 4: ESP-NOW time-sync valid.
assert_eq!(frame.metadata.ppdu_type, PpduType::HeSu);
assert!(frame.metadata.ppdu_type.is_he());
assert!(frame.metadata.adr018_flags.ieee802154_sync_valid);
assert!(!frame.metadata.adr018_flags.bw40);
// 256-FFT HE-LTF on a 20 MHz channel — NOT 160 MHz.
assert_eq!(frame.metadata.bandwidth, Bandwidth::Bw20);
assert!(frame.is_valid());
}
#[test]
fn live_ht_frame_148_bytes_parses_with_64_subcarriers() {
let data = unhex(HT_FRAME_HEX);
assert_eq!(data.len(), 148);
let (frame, consumed) = Esp32CsiParser::parse_frame(&data).expect("HT frame must parse");
assert_eq!(consumed, 148);
assert_eq!(frame.metadata.node_id, 12);
assert_eq!(frame.metadata.n_subcarriers, 64);
assert_eq!(frame.metadata.channel_freq_mhz, 2432);
assert_eq!(frame.metadata.sequence, 11622);
assert_eq!(frame.metadata.rssi_dbm, -79);
assert_eq!(frame.metadata.noise_floor_dbm, -87);
assert_eq!(frame.metadata.ppdu_type, PpduType::HtLegacy);
assert!(!frame.metadata.ppdu_type.is_he());
// 64-bin full HT20 FFT grid on a 20 MHz channel — NOT 40 MHz.
assert_eq!(frame.metadata.bandwidth, Bandwidth::Bw20);
assert!(frame.is_valid());
}
#[test]
fn live_interleaved_stream_parses_both_grids() {
// The live node interleaves HE (84%) and HT (16%) frames on one socket.
let mut stream = unhex(HE_FRAME_HEX);
stream.extend_from_slice(&unhex(HT_FRAME_HEX));
stream.extend_from_slice(&unhex(HE_FRAME_HEX));
let (frames, consumed) = Esp32CsiParser::parse_stream(&stream);
assert_eq!(frames.len(), 3);
assert_eq!(consumed, 532 + 148 + 532);
assert_eq!(frames[0].metadata.n_subcarriers, 256);
assert_eq!(frames[1].metadata.n_subcarriers, 64);
assert_eq!(frames[2].metadata.n_subcarriers, 256);
assert_eq!(frames[0].metadata.ppdu_type, PpduType::HeSu);
assert_eq!(frames[1].metadata.ppdu_type, PpduType::HtLegacy);
}
@@ -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,
}
}
@@ -13,19 +13,19 @@ use std::time::Duration;
/// Build a minimal valid ESP32 CSI frame (magic 0xC511_0001).
///
/// Format (ADR-018):
/// [0..3] magic: 0xC511_0001 (LE)
/// [4] node_id
/// [5] n_antennas (1)
/// [6] n_subcarriers (e.g., 32)
/// [7] reserved
/// [8..9] freq_mhz (2437 = channel 6)
/// [10..13] sequence (LE u32)
/// [14] rssi (signed)
/// [15] noise_floor
/// [16..19] reserved
/// [20..] I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u8) -> Vec<u8> {
/// Format (ADR-018, authoritative: firmware `csi_collector.c`):
/// [0..3] magic: 0xC511_0001 (LE)
/// [4] node_id
/// [5] n_antennas (1)
/// [6..7] n_subcarriers (LE u16 — 256 for ESP32-C6 HE-SU, issue #1005)
/// [8..11] freq_mhz (LE u32, 2437 = channel 6)
/// [12..15] sequence (LE u32)
/// [16] rssi (signed)
/// [17] noise_floor
/// [18] PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU)
/// [19] flags (ADR-110)
/// [20..] I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u16) -> Vec<u8> {
let n_pairs = n_sub as usize;
let mut buf = vec![0u8; 20 + n_pairs * 2];
@@ -35,18 +35,19 @@ fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u8) -> Vec<u8> {
buf[4] = node_id;
buf[5] = 1; // n_antennas
buf[6] = n_sub;
buf[7] = 0;
buf[6..8].copy_from_slice(&n_sub.to_le_bytes());
// freq = 2437 MHz (channel 6)
let freq: u16 = 2437;
buf[8..10].copy_from_slice(&freq.to_le_bytes());
let freq: u32 = 2437;
buf[8..12].copy_from_slice(&freq.to_le_bytes());
// sequence
buf[10..14].copy_from_slice(&seq.to_le_bytes());
buf[12..16].copy_from_slice(&seq.to_le_bytes());
buf[14] = rssi as u8;
buf[15] = (-90i8) as u8; // noise floor
buf[16] = rssi as u8;
buf[17] = (-90i8) as u8; // noise floor
buf[18] = u8::from(n_sub >= 256); // ADR-110 PPDU type: HE-SU for 256-bin
buf[19] = 0; // ADR-110 flags
// Generate I/Q pairs with node-specific patterns.
// Different nodes produce different amplitude patterns so the server
@@ -136,7 +137,7 @@ fn test_multi_node_udp_send() {
sock.set_write_timeout(Some(Duration::from_millis(100)))
.ok();
let n_sub = 32u8;
let n_sub = 32u16;
let node_ids = [1u8, 2, 3, 5, 7];
for &nid in &node_ids {
@@ -161,11 +162,13 @@ fn test_multi_node_udp_send() {
/// size for various subcarrier counts (boundary testing).
#[test]
fn test_frame_sizes() {
for n_sub in [1u8, 16, 32, 52, 56, 64, 128] {
// 256 = ESP32-C6 HE-SU grid (issue #1005) → 532-byte frame as on the wire.
for n_sub in [1u16, 16, 32, 52, 56, 64, 128, 256] {
let frame = build_csi_frame(1, 0, -50, n_sub);
let expected = 20 + (n_sub as usize) * 2;
assert_eq!(frame.len(), expected, "wrong size for n_sub={n_sub}");
}
assert_eq!(build_csi_frame(1, 0, -50, 256).len(), 532);
}
/// Simulate a mesh of N nodes sending frames at different rates.