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/docs/WITNESS-LOG-110.md b/docs/WITNESS-LOG-110.md index 2b6dbcfb..02ee5e48 100644 --- a/docs/WITNESS-LOG-110.md +++ b/docs/WITNESS-LOG-110.md @@ -57,7 +57,7 @@ This witness separates what was **empirically observed on real silicon today** f | # | Claim | Why it's not verified | |---|---|---| -| **B1** | "Wi-Fi 6 HE-LTF: 242 subcarriers per HE20 frame" | The only AP in range (`ruv.net`) is 11n-only. Every captured frame is 128 bytes = 64 subcarriers (HT-LTF, `ppdu_type=0`). No HE-SU/HE-MU/HE-TB observed. Even if an 11ax AP were available, **whether ESP-IDF v5.4's CSI callback exposes HE-LTF subcarriers via `wifi_csi_info_t.buf` is an open question** — the public API was designed for HT-LTF, and the driver may quietly downconvert. **Validate by capturing CSI against an 11ax AP and comparing `info->len` between HT and HE frames.** | +| **B1** | "Wi-Fi 6 HE-LTF: 242 subcarriers per HE20 frame" | The only AP in range (`ruv.net`) is 11n-only. Every captured frame is 128 bytes = 64 subcarriers (HT-LTF, `ppdu_type=0`). No HE-SU/HE-MU/HE-TB observed. Even if an 11ax AP were available, **whether ESP-IDF v5.4's CSI callback exposes HE-LTF subcarriers via `wifi_csi_info_t.buf` is an open question** — the public API was designed for HT-LTF, and the driver may quietly downconvert. **Validate by capturing CSI against an 11ax AP and comparing `info->len` between HT and HE frames.**

**RESOLVED WITH MEASUREMENT (2026-06-11, external — issue #1005, production deployment by @stuinfla):** the open question is answered in both directions. **IDF v5.4's driver blob downconverts** (148 B / 64-subcarrier HT frames, PPDU byte 0x00, on a confirmed-HE link); **IDF v5.5.2 delivers true HE-LTF** — 532 B frames = 256 bins (242 active HE20 tones), PPDU byte 0x01 (HE-SU), ~90% of frames, same board/AP/link. Setup: XIAO ESP32-C6 → hostapd on Intel AX210, 2.4 GHz ch 6, `ieee80211ax=1`. No firmware change required (`acquire_csi_su=1` was already set); the gate was purely the IDF driver version. Three C6 nodes ran this mode simultaneously with ADR-110 ESP-NOW sync. Requires the issue-#1005 version-guard fix in `c6_sync_espnow.c` to build on v5.5.x. |

**REPLICATED IN-HOUSE (2026-06-11):** same source + fix, fresh IDF v5.5.2 toolchain, original COM12 board (`20:6e:f1:17:00:84`), AP `ruv.net` (11ax 2.4 GHz): **84% of 1,525 captured frames at 532 B / PPDU 0x01 (HE-SU)**, HT minority 148 B / 0x00. Evidence grade: MEASURED (two independent rigs). | | **B2** | "TWT-bounded deterministic CSI cadence (10 ms wake)" | No 11ax AP in range. The TWT setup *call* was exercised live and the graceful fallback path is now correct (A9), but the agreement itself was never accepted. **Validate by associating with an 11ax AP that has TWT Responder=1, then capturing the timestamped CSI cadence vs the wall clock.** | | **B3** | "±100 µs cross-node alignment over 802.15.4" | 3 boards initialized their radios with correct EUIs (A4/A5), but **none stepped down from candidate-leader to follower** during repeated 35-second multi-board captures.

**Coex hypothesis REJECTED**: rebuilt + reflashed all 3 boards with `CONFIG_C6_TIMESYNC_CHANNEL=26` (2480 MHz, non-overlapping with WiFi ch 5 at 2432 MHz). Result identical: 3× candidate, 0× "stepping down". So 2.4 GHz radio coex was NOT the cause.

**Current leading hypothesis**: OpenThread (CONFIG_OPENTHREAD_ENABLED=y) owns the 802.15.4 radio when its stack is initialized — our weak-symbol overrides of `esp_ieee802154_receive_done` / `_transmit_done` may never be called because OpenThread registers strong handlers. Validation in progress: rebuilding with `CONFIG_OPENTHREAD_ENABLED=n` (raw 802.15.4 only, our beacon protocol is private — no need for the Thread stack). If leader election fires under raw-15.4-only, hypothesis confirmed.

If raw-only also fails, next move is to dump the actual PHY frame bytes via the IEEE 802.15.4 sniffer mode on a 4th board and diagnose at the frame level. | | **B4** | "~5 µA hibernation for battery seed nodes" | No INA / Joulescope current measurement available on this bench. The shipped code uses `esp_deep_sleep_enable_gpio_wakeup` (ext1 path, ESP-IDF default ~10 µA), not a true LP-core polling program. The 5 µA number is the C6 datasheet figure for ULP-level hibernation, not a measured value. **Validate by hooking an INA219/INA226 between the dev board's 3V3 rail and the regulator output, then averaging current over a 60-second cycle with the LP-core armed.** | diff --git a/docs/adr/ADR-110-esp32-c6-firmware-extension.md b/docs/adr/ADR-110-esp32-c6-firmware-extension.md index 4d325bf2..3905f167 100644 --- a/docs/adr/ADR-110-esp32-c6-firmware-extension.md +++ b/docs/adr/ADR-110-esp32-c6-firmware-extension.md @@ -19,7 +19,7 @@ The production CSI node firmware (`firmware/esp32-csi-node`) was built around th | C6 capability | What it enables for sensing | Why we can't get it on S3 | |---|---|---| -| **802.11ax (Wi-Fi 6) HE-LTF CSI** | 242 subcarriers per HE20 frame (vs 52 for HT-LTF), HE-MU/HE-TB PPDU types, OFDMA-aware channel sounding | S3 radio is HT-only (n) | +| **802.11ax (Wi-Fi 6) HE-LTF CSI** | 242 subcarriers per HE20 frame (vs 52 for HT-LTF), HE-MU/HE-TB PPDU types, OFDMA-aware channel sounding. **Hardware-confirmed 2026-06-11** (issue #1005, external production deployment): requires **ESP-IDF ≥ 5.5** — the v5.4 driver blob silently downconverts to 64-subcarrier HT even on a confirmed-HE link; v5.5.2 delivers 532 B frames = 256 bins (242 active tones), PPDU 0x01 (HE-SU). See WITNESS-LOG-110 §B1 (resolved). | S3 radio is HT-only (n) | | **802.15.4 (Thread / Zigbee)** | Cross-node time-sync over a separate radio — frees Wi-Fi airtime for CSI, ±100 µs alignment possible without coordination traffic on the sensing channel | S3 has no 802.15.4 | | **TWT (Target Wake Time)** | Sensor negotiates a deterministic wake slot with the AP; CSI cadence becomes scheduler-bounded instead of opportunistic | Requires 802.11ax — S3 can't speak it | | **LP-core + hibernation (~5 µA)** | Always-on motion gate runs on a separate RISC-V LP core in deep sleep; HP core stays off until a real event | S3 ULP is FSM-only, ~10 µA floor | diff --git a/firmware/esp32-csi-node/main/c6_sync_espnow.c b/firmware/esp32-csi-node/main/c6_sync_espnow.c index 39c5625f..83581229 100644 --- a/firmware/esp32-csi-node/main/c6_sync_espnow.c +++ b/firmware/esp32-csi-node/main/c6_sync_espnow.c @@ -151,9 +151,13 @@ static void on_recv(const uint8_t *src_mac, const uint8_t *data, int len) * void (*)(const esp_now_send_info_t *tx_info, esp_now_send_status_t status) * Both signatures ignore the address-side argument here — we only inspect * `status` to bump the TX-fail counter — so the body is identical; only the - * function-pointer type differs. ESP_IDF_VERSION_MAJOR is the canonical guard. + * function-pointer type differs. + * + * Issue #1005: Espressif backported the new signature to v5.5 + * (`esp_now_send_info_t` = typedef of `wifi_tx_info_t` there), so the guard + * must be the full version triple, not ESP_IDF_VERSION_MAJOR. */ -#if ESP_IDF_VERSION_MAJOR >= 6 +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) static void on_send(const esp_now_send_info_t *tx_info, esp_now_send_status_t status) { (void)tx_info; diff --git a/firmware/esp32-csi-node/test/stubs/esp_netif.h b/firmware/esp32-csi-node/test/stubs/esp_netif.h new file mode 100644 index 00000000..89ff9f60 --- /dev/null +++ b/firmware/esp32-csi-node/test/stubs/esp_netif.h @@ -0,0 +1,48 @@ +/* Host-fuzzing stub for esp_netif.h (ADR-061). + * + * csi_collector.c's #954 self-ping needs the STA netif handle + gateway IP. + * In the fuzz environment there is no network stack: the handle lookup + * returns NULL, so csi_start_self_ping() takes its no-gateway early-out and + * the esp_ping path is never exercised (but must compile and link). + */ +#pragma once + +#include +#include + +#include "esp_err.h" + +typedef struct esp_netif_obj esp_netif_t; + +typedef struct { + uint32_t addr; +} esp_ip4_addr_t; + +typedef struct { + esp_ip4_addr_t ip; + esp_ip4_addr_t netmask; + esp_ip4_addr_t gw; +} esp_netif_ip_info_t; + +static inline esp_netif_t *esp_netif_get_handle_from_ifkey(const char *if_key) +{ + (void)if_key; + return NULL; /* no netif in fuzz env -> self-ping early-out */ +} + +static inline esp_err_t esp_netif_get_ip_info(esp_netif_t *netif, esp_netif_ip_info_t *ip_info) +{ + (void)netif; + (void)ip_info; + return ESP_FAIL; +} + +static inline char *esp_ip4addr_ntoa(const esp_ip4_addr_t *addr, char *buf, int buflen) +{ + if (buf != NULL && buflen > 0) { + snprintf(buf, (size_t)buflen, "%u.%u.%u.%u", + (unsigned)(addr->addr & 0xff), (unsigned)((addr->addr >> 8) & 0xff), + (unsigned)((addr->addr >> 16) & 0xff), (unsigned)((addr->addr >> 24) & 0xff)); + } + return buf; +} diff --git a/firmware/esp32-csi-node/test/stubs/lwip/ip_addr.h b/firmware/esp32-csi-node/test/stubs/lwip/ip_addr.h new file mode 100644 index 00000000..0b979be2 --- /dev/null +++ b/firmware/esp32-csi-node/test/stubs/lwip/ip_addr.h @@ -0,0 +1,20 @@ +/* Host-fuzzing stub for lwip/ip_addr.h (ADR-061). Minimal surface for the + * #954 self-ping block; never functionally exercised in the fuzz env. */ +#pragma once + +#include + +typedef struct { + uint32_t addr; + uint8_t type; +} ip_addr_t; + +static inline int ipaddr_aton(const char *cp, ip_addr_t *addr) +{ + (void)cp; + if (addr != NULL) { + addr->addr = 0; + addr->type = 0; + } + return 1; +} diff --git a/firmware/esp32-csi-node/test/stubs/ping/ping_sock.h b/firmware/esp32-csi-node/test/stubs/ping/ping_sock.h new file mode 100644 index 00000000..89e993f2 --- /dev/null +++ b/firmware/esp32-csi-node/test/stubs/ping/ping_sock.h @@ -0,0 +1,79 @@ +/* Host-fuzzing stub for ping/ping_sock.h (ADR-061). The #954 self-ping is + * unreachable in the fuzz env (esp_netif stub returns no gateway), but the + * symbols must compile and link. */ +#pragma once + +#include + +#include "esp_err.h" +#include "lwip/ip_addr.h" + +typedef void *esp_ping_handle_t; + +typedef void (*esp_ping_cb_t)(esp_ping_handle_t hdl, void *args); + +typedef struct { + uint32_t count; + uint32_t interval_ms; + uint32_t timeout_ms; + uint32_t data_size; + uint8_t tos; + int ttl; + ip_addr_t target_addr; + uint32_t task_stack_size; + uint32_t task_prio; + uint32_t interface; +} esp_ping_config_t; + +#define ESP_PING_COUNT_INFINITE (0) + +#define ESP_PING_DEFAULT_CONFIG() \ + { \ + .count = 5, \ + .interval_ms = 1000, \ + .timeout_ms = 1000, \ + .data_size = 64, \ + .tos = 0, \ + .ttl = 64, \ + .target_addr = {0, 0}, \ + .task_stack_size = 2048, \ + .task_prio = 2, \ + .interface = 0, \ + } + +typedef struct { + void *cb_args; + esp_ping_cb_t on_ping_success; + esp_ping_cb_t on_ping_timeout; + esp_ping_cb_t on_ping_end; +} esp_ping_callbacks_t; + +static inline esp_err_t esp_ping_new_session(const esp_ping_config_t *config, + const esp_ping_callbacks_t *cbs, + esp_ping_handle_t *hdl_out) +{ + (void)config; + (void)cbs; + if (hdl_out != NULL) { + *hdl_out = (void *)0; + } + return ESP_FAIL; /* never starts a ping task in the fuzz env */ +} + +static inline esp_err_t esp_ping_start(esp_ping_handle_t hdl) +{ + (void)hdl; + return ESP_OK; +} + +static inline esp_err_t esp_ping_stop(esp_ping_handle_t hdl) +{ + (void)hdl; + return ESP_OK; +} + +static inline esp_err_t esp_ping_delete_session(esp_ping_handle_t hdl) +{ + (void)hdl; + return ESP_OK; +} 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.