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.