diff --git a/archive/v1/src/hardware/csi_extractor.py b/archive/v1/src/hardware/csi_extractor.py index a040806c..67a28ebd 100644 --- a/archive/v1/src/hardware/csi_extractor.py +++ b/archive/v1/src/hardware/csi_extractor.py @@ -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 diff --git a/v2/crates/wifi-densepose-cli/src/calibrate.rs b/v2/crates/wifi-densepose-cli/src/calibrate.rs index 17d50b27..d4c261ff 100644 --- a/v2/crates/wifi-densepose-cli/src/calibrate.rs +++ b/v2/crates/wifi-densepose-cli/src/calibrate.rs @@ -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 { 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 { + 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] diff --git a/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs b/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs index 455b1451..fffe3a22 100644 --- a/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs +++ b/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs @@ -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 { diff --git a/v2/crates/wifi-densepose-hardware/src/lib.rs b/v2/crates/wifi-densepose-hardware/src/lib.rs index 63c45d1b..382e76ed 100644 --- a/v2/crates/wifi-densepose-hardware/src/lib.rs +++ b/v2/crates/wifi-densepose-hardware/src/lib.rs @@ -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, diff --git a/v2/crates/wifi-densepose-hardware/tests/adr110_live_frames.rs b/v2/crates/wifi-densepose-hardware/tests/adr110_live_frames.rs new file mode 100644 index 00000000..d9d84645 --- /dev/null +++ b/v2/crates/wifi-densepose-hardware/tests/adr110_live_frames.rs @@ -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 { + (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); +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/csi.rs b/v2/crates/wifi-densepose-sensing-server/src/csi.rs index 3f905dcc..a4853c8b 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/csi.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/csi.rs @@ -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 { }) } +/// 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 { if buf.len() < 20 { return None; @@ -95,16 +108,18 @@ pub fn parse_esp32_frame(buf: &[u8]) -> Option { 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 { 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 { + (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())); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 2d954e2a..910d037d 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -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, phases: Vec, } +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, + /// 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 { // [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 { 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()); diff --git a/v2/crates/wifi-densepose-sensing-server/src/types.rs b/v2/crates/wifi-densepose-sensing-server/src/types.rs index afb7683f..b2b39e77 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/types.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/types.rs @@ -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, pub phases: Vec, } +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, + /// 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, } 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, } } diff --git a/v2/crates/wifi-densepose-sensing-server/tests/multi_node_test.rs b/v2/crates/wifi-densepose-sensing-server/tests/multi_node_test.rs index 0d438ff4..643eaa90 100644 --- a/v2/crates/wifi-densepose-sensing-server/tests/multi_node_test.rs +++ b/v2/crates/wifi-densepose-sensing-server/tests/multi_node_test.rs @@ -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 { +/// 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 { 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 { 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.