mirror of
https://github.com/ruvnet/RuView
synced 2026-06-12 10:43:19 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0e27e652e |
@@ -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
|
||||
|
||||
@@ -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.**<br><br>**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. |<br><br>**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. <br><br>**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. <br><br>**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. <br><br>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.** |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -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 <stdint.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 <stdint.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -8,22 +8,24 @@
|
||||
//!
|
||||
//! # Wire format parsed here (option b — local parser, no cross-crate dep)
|
||||
//!
|
||||
//! Authoritative layout: firmware `csi_collector.c` (ADR-018 + ADR-110).
|
||||
//!
|
||||
//! Offset Size Field
|
||||
//! ────── ──── ─────────────────────────────────────────────────────────────
|
||||
//! 0 4 Magic: 0xC511_0001 (LE u32)
|
||||
//! 4 1 node_id (u8)
|
||||
//! 5 1 n_antennas (u8)
|
||||
//! 6 1 n_subcarriers (u8)
|
||||
//! 7 1 (reserved)
|
||||
//! 8 2 freq_mhz (LE u16)
|
||||
//! 10 4 sequence (LE u32)
|
||||
//! 14 1 rssi (i8)
|
||||
//! 15 1 noise_floor (i8)
|
||||
//! 16 4 (reserved / padding)
|
||||
//! 6 2 n_subcarriers (LE u16 — 256 for ESP32-C6 HE-SU frames, #1005)
|
||||
//! 8 4 freq_mhz (LE u32)
|
||||
//! 12 4 sequence (LE u32)
|
||||
//! 16 1 rssi (i8)
|
||||
//! 17 1 noise_floor (i8)
|
||||
//! 18 1 PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB)
|
||||
//! 19 1 flags (ADR-110: bit0 bw40, bit4 time-sync valid)
|
||||
//! 20 2 × n_antennas × n_subcarriers IQ pairs: i_val (i8), q_val (i8)
|
||||
//!
|
||||
//! This parser mirrors `parse_esp32_frame` in
|
||||
//! `wifi-densepose-sensing-server/src/csi.rs` exactly (same magic, same layout).
|
||||
//! `wifi-densepose-sensing-server/src/csi.rs` (same magic, same layout).
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use clap::Args;
|
||||
@@ -261,11 +263,15 @@ pub(crate) fn parse_csi_packet(buf: &[u8], tier: &str) -> Option<CsiFrame> {
|
||||
|
||||
let node_id = buf[4];
|
||||
let n_antennas = buf[5] as usize;
|
||||
let n_subcarriers = buf[6] as usize;
|
||||
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
|
||||
let _sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
|
||||
let rssi = buf[14] as i8;
|
||||
let noise_floor = buf[15] as i8;
|
||||
// u16 since ADR-110 / #1005: ESP32-C6 HE-SU frames carry 256 bins
|
||||
// (the old single-byte read decoded 256 = 0x0100 LE as 0 subcarriers).
|
||||
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]) as usize;
|
||||
let freq_mhz = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
|
||||
let freq_mhz = u16::try_from(freq_mhz).unwrap_or(0);
|
||||
let _sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
|
||||
let rssi = buf[16] as i8;
|
||||
let noise_floor = buf[17] as i8;
|
||||
let _ppdu_type = buf[18]; // ADR-110; baseline tier gating is by count
|
||||
|
||||
let n_pairs = n_antennas * n_subcarriers;
|
||||
let iq_start = 20usize;
|
||||
@@ -414,24 +420,53 @@ mod tests {
|
||||
assert!(parse_csi_packet(&buf, "ht20").is_none());
|
||||
}
|
||||
|
||||
/// Build an ADR-018 frame (correct firmware layout, ADR-110 bytes 18-19).
|
||||
fn build_frame(n_subcarriers: u16, ppdu: u8) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 20 + n_subcarriers as usize * 2];
|
||||
buf[0..4].copy_from_slice(&0xC511_0001u32.to_le_bytes());
|
||||
buf[4] = 12; // node_id
|
||||
buf[5] = 1; // n_antennas
|
||||
buf[6..8].copy_from_slice(&n_subcarriers.to_le_bytes());
|
||||
buf[8..12].copy_from_slice(&2432u32.to_le_bytes()); // freq_mhz
|
||||
buf[12..16].copy_from_slice(&11610u32.to_le_bytes()); // sequence
|
||||
buf[16] = (-40i8) as u8; // rssi
|
||||
buf[17] = (-87i8) as u8; // noise floor
|
||||
buf[18] = ppdu;
|
||||
buf[19] = 0x10; // time-sync valid
|
||||
for k in 0..n_subcarriers as usize {
|
||||
buf[20 + k * 2] = (10 + (k % 100) as i8) as u8;
|
||||
buf[20 + k * 2 + 1] = (k % 50) as u8;
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_csi_packet_valid() {
|
||||
let mut buf = vec![0u8; 24]; // 20-byte header + 2 IQ pairs (1 antenna, 2 subcarriers)
|
||||
// Magic 0xC511_0001 LE
|
||||
buf[0] = 0x01; buf[1] = 0x00; buf[2] = 0x11; buf[3] = 0xC5;
|
||||
buf[5] = 1; // n_antennas
|
||||
buf[6] = 2; // n_subcarriers
|
||||
// freq_mhz = 2437 (channel 6)
|
||||
buf[8] = 0x85; buf[9] = 0x09;
|
||||
// IQ pairs at offset 20: (10, 20), (−5, 15)
|
||||
buf[20] = 10i8 as u8; buf[21] = 20i8 as u8;
|
||||
buf[22] = (-5i8) as u8; buf[23] = 15i8 as u8;
|
||||
|
||||
let buf = build_frame(2, 0);
|
||||
let frame = parse_csi_packet(&buf, "ht20");
|
||||
assert!(frame.is_some());
|
||||
let f = frame.unwrap();
|
||||
assert_eq!(f.num_spatial_streams(), 1);
|
||||
assert_eq!(f.num_subcarriers(), 2);
|
||||
assert_eq!(f.metadata.rssi_dbm, -40);
|
||||
assert_eq!(f.metadata.noise_floor_dbm, -87);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_csi_packet_he_su_256_bins() {
|
||||
// ESP32-C6 HE-SU frame (issue #1005): n_subcarriers = 256 = 0x0100 LE.
|
||||
// The pre-#1005 single-byte read decoded this as 0 subcarriers.
|
||||
let buf = build_frame(256, 1);
|
||||
assert_eq!(buf.len(), 532); // matches the live wire size
|
||||
let f = parse_csi_packet(&buf, "he20").expect("256-bin HE frame must parse");
|
||||
assert_eq!(f.num_subcarriers(), 256);
|
||||
assert_eq!(f.metadata.rssi_dbm, -40);
|
||||
// A 256-bin frame is accepted by the he20 recorder (num_subcarriers
|
||||
// tier total) and rejected by ht20 (52/64) — no HT/HE mixing.
|
||||
let mut he = wifi_densepose_signal::CalibrationRecorder::new(tier_config("he20"));
|
||||
assert!(he.record(&f).is_ok());
|
||||
let mut ht = wifi_densepose_signal::CalibrationRecorder::new(tier_config("ht20"));
|
||||
assert!(ht.record(&f).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
//! 12 4 Sequence number (LE u32)
|
||||
//! 16 1 RSSI (i8)
|
||||
//! 17 1 Noise floor (i8)
|
||||
//! 18 2 Reserved
|
||||
//! 18 1 PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB)
|
||||
//! 19 1 Flags (ADR-110: bit0 bw40, bit2 STBC, bit3 LDPC, bit4 15.4-sync)
|
||||
//! 20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
|
||||
//! ```
|
||||
//!
|
||||
@@ -240,12 +241,31 @@ impl Esp32CsiParser {
|
||||
}
|
||||
}
|
||||
|
||||
// Determine bandwidth from subcarrier count
|
||||
let bandwidth = match n_subcarriers {
|
||||
0..=56 => Bandwidth::Bw20,
|
||||
57..=114 => Bandwidth::Bw40,
|
||||
115..=242 => Bandwidth::Bw80,
|
||||
_ => Bandwidth::Bw160,
|
||||
// Determine bandwidth from PPDU type + subcarrier count (ADR-110).
|
||||
//
|
||||
// HE-LTF uses a 4x denser tone grid than HT-LTF on the same channel
|
||||
// width: HE20 = 256-FFT (242 active tones), HE40 = 512-FFT (484
|
||||
// active). So a 256-bin frame on an HE PPDU is *20 MHz*, not 160.
|
||||
// For HE frames the firmware also writes the bandwidth into byte 19
|
||||
// bit 0 (see Adr018Flags::bw40) — prefer that when set.
|
||||
//
|
||||
// HT/legacy keeps the count heuristic, with 64 included in the 20 MHz
|
||||
// bucket: ESP32 HT20 CSI delivers the full 64-bin FFT grid (live
|
||||
// capture evidence: 148-byte frames = 64 subcarriers on a 20 MHz
|
||||
// channel, issue #1005).
|
||||
let bandwidth = if ppdu_type.is_he() {
|
||||
if adr018_flags.bw40 || n_subcarriers > 256 {
|
||||
Bandwidth::Bw40
|
||||
} else {
|
||||
Bandwidth::Bw20
|
||||
}
|
||||
} else {
|
||||
match n_subcarriers {
|
||||
0..=64 => Bandwidth::Bw20,
|
||||
65..=128 => Bandwidth::Bw40,
|
||||
129..=242 => Bandwidth::Bw80,
|
||||
_ => Bandwidth::Bw160,
|
||||
}
|
||||
};
|
||||
|
||||
let frame = CsiFrame {
|
||||
|
||||
@@ -49,7 +49,9 @@ pub mod sync_packet;
|
||||
pub mod radio_ops;
|
||||
|
||||
pub use bridge::CsiData;
|
||||
pub use csi_frame::{AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, SubcarrierData};
|
||||
pub use csi_frame::{
|
||||
Adr018Flags, AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, PpduType, SubcarrierData,
|
||||
};
|
||||
pub use error::ParseError;
|
||||
pub use esp32_parser::{
|
||||
ruview_sibling_packet_name, Esp32CsiParser, ESP32_CSI_MAGIC, RUVIEW_COMPRESSED_CSI_MAGIC,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! ADR-110 / issue #1005: real ESP32-C6 HE-LTF CSI frames captured live.
|
||||
//!
|
||||
//! Both fixtures below are verbatim UDP payloads captured on 2026-06-11 from
|
||||
//! an ESP32-C6 (node_id 12, IDF v5.5 build) streaming to UDP :5005 — the
|
||||
//! same node, same link, seconds apart. The 532-byte frame is an HE-SU
|
||||
//! capture (256 subcarrier bins = 242 active HE20 tones); the 148-byte frame
|
||||
//! is the HT fallback grid (64 bins) the same firmware emits for non-HE
|
||||
//! traffic. They are the canonical regression fixtures for the non-fixed
|
||||
//! subcarrier count introduced by HE-LTF.
|
||||
|
||||
use wifi_densepose_hardware::{Bandwidth, Esp32CsiParser, PpduType};
|
||||
|
||||
/// 532-byte HE-SU frame: header + 256 subcarrier I/Q pairs.
|
||||
/// magic=0xC5110001 node=12 ant=1 nsub=256 freq=2432 seq=11610
|
||||
/// rssi=-40 noise=-87 byte18=0x01 (HE-SU) byte19=0x10 (15.4-sync valid)
|
||||
const HE_FRAME_HEX: &str = "010011c50c010001800900005a2d0000d8a9011000000000000000000000f70ef70ef50cf30bf209f108f006ef03ee02ee00eefdeffbeff8f0f7f1f4f2f3f4f1f5f0f7eef8edfaecfdecffeb01ea03ea05e908ea0aeb0deb0fec11ee13f015f216f318f519f71afa1bfd1bff1c021c051b071b0a1a0c190f1811161315161218101a0e1b0c1c091d071e041f0120ff20fc20f91ff71ff41ef11def1cec1be919e717e615e413e311e10edf0cde09dd06dc04dc01dcffdcfbdcf9ddf6def3dff0e0ede2eae4e8e6e6e8e4eae2ebe0eedef1dcf4dbf7dafad9fdd900d903d806d909d90cda0fdc12dc14dd17df1ae11ce31ee520e722e924ed25f127f328f629f929fd2900290329062809270c260e26122516061a00001c201c1f1a211722142411250e260c27082804280129fe29fb28f927f627f426f125ef23ec22ea20e81eea20e81e891b53a82951565d4ffafbfebe9abddb10222aa47b3b371fd2c0860cd4d86ea2f35faccd46b0b66f6ff0050f2da27d1c92f7f8e1017cb545afd3e3fe60db6f478dc85a33b3454cf6df9061194a0a0fc3e0eedf76f1d292cb25c8f541dfcc4109f9f1a34955520ad8ffa3694ac395cbf6c19073a4aefb1ebf47c76730458431805d9f18ff2e81955e8752b29757f66e289f72f8e35309a737547c040444cbda1a81d221d950037ec38fd9d1dd0f56c3dc707a7bbfe66ca5a97ab7cc17d68d38ba43a1806f91f5911a5967e2c9f7f07186";
|
||||
|
||||
/// 148-byte HT frame from the same node: header + 64 subcarrier I/Q pairs.
|
||||
/// magic=0xC5110001 node=12 ant=1 nsub=64 freq=2432 seq=11622
|
||||
/// rssi=-79 noise=-87 byte18=0x00 (HT/legacy) byte19=0x10
|
||||
const HT_FRAME_HEX: &str = "010011c50c01400080090000662d0000b1a900100000000000000000fcfaf909f013f112f213f212f311f410f511f510f610f510f411f410f411f312f213f214f214f212f313f513f512f611f610f80ef90df90c0000010eff11fe13ff11fe1300000000ff01000001010002000200020204000301040103000400040002ff03ff03fe02fe02fe01fd00edfc03fa000000000000";
|
||||
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_he_su_frame_532_bytes_parses_with_256_subcarriers() {
|
||||
let data = unhex(HE_FRAME_HEX);
|
||||
assert_eq!(data.len(), 532);
|
||||
|
||||
let (frame, consumed) = Esp32CsiParser::parse_frame(&data).expect("HE frame must parse");
|
||||
assert_eq!(consumed, 532);
|
||||
assert_eq!(frame.metadata.node_id, 12);
|
||||
assert_eq!(frame.metadata.n_antennas, 1);
|
||||
assert_eq!(frame.metadata.n_subcarriers, 256);
|
||||
assert_eq!(frame.subcarrier_count(), 256);
|
||||
assert_eq!(frame.metadata.channel_freq_mhz, 2432);
|
||||
assert_eq!(frame.metadata.sequence, 11610);
|
||||
assert_eq!(frame.metadata.rssi_dbm, -40);
|
||||
assert_eq!(frame.metadata.noise_floor_dbm, -87);
|
||||
// ADR-110 byte 18: HE-SU PPDU. Byte 19 bit 4: ESP-NOW time-sync valid.
|
||||
assert_eq!(frame.metadata.ppdu_type, PpduType::HeSu);
|
||||
assert!(frame.metadata.ppdu_type.is_he());
|
||||
assert!(frame.metadata.adr018_flags.ieee802154_sync_valid);
|
||||
assert!(!frame.metadata.adr018_flags.bw40);
|
||||
// 256-FFT HE-LTF on a 20 MHz channel — NOT 160 MHz.
|
||||
assert_eq!(frame.metadata.bandwidth, Bandwidth::Bw20);
|
||||
assert!(frame.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_ht_frame_148_bytes_parses_with_64_subcarriers() {
|
||||
let data = unhex(HT_FRAME_HEX);
|
||||
assert_eq!(data.len(), 148);
|
||||
|
||||
let (frame, consumed) = Esp32CsiParser::parse_frame(&data).expect("HT frame must parse");
|
||||
assert_eq!(consumed, 148);
|
||||
assert_eq!(frame.metadata.node_id, 12);
|
||||
assert_eq!(frame.metadata.n_subcarriers, 64);
|
||||
assert_eq!(frame.metadata.channel_freq_mhz, 2432);
|
||||
assert_eq!(frame.metadata.sequence, 11622);
|
||||
assert_eq!(frame.metadata.rssi_dbm, -79);
|
||||
assert_eq!(frame.metadata.noise_floor_dbm, -87);
|
||||
assert_eq!(frame.metadata.ppdu_type, PpduType::HtLegacy);
|
||||
assert!(!frame.metadata.ppdu_type.is_he());
|
||||
// 64-bin full HT20 FFT grid on a 20 MHz channel — NOT 40 MHz.
|
||||
assert_eq!(frame.metadata.bandwidth, Bandwidth::Bw20);
|
||||
assert!(frame.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_interleaved_stream_parses_both_grids() {
|
||||
// The live node interleaves HE (84%) and HT (16%) frames on one socket.
|
||||
let mut stream = unhex(HE_FRAME_HEX);
|
||||
stream.extend_from_slice(&unhex(HT_FRAME_HEX));
|
||||
stream.extend_from_slice(&unhex(HE_FRAME_HEX));
|
||||
|
||||
let (frames, consumed) = Esp32CsiParser::parse_stream(&stream);
|
||||
assert_eq!(frames.len(), 3);
|
||||
assert_eq!(consumed, 532 + 148 + 532);
|
||||
assert_eq!(frames[0].metadata.n_subcarriers, 256);
|
||||
assert_eq!(frames[1].metadata.n_subcarriers, 64);
|
||||
assert_eq!(frames[2].metadata.n_subcarriers, 256);
|
||||
assert_eq!(frames[0].metadata.ppdu_type, PpduType::HeSu);
|
||||
assert_eq!(frames[1].metadata.ppdu_type, PpduType::HtLegacy);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use wifi_densepose_hardware::PpduType;
|
||||
|
||||
use crate::adaptive_classifier;
|
||||
use crate::types::*;
|
||||
@@ -84,6 +85,18 @@ pub fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse an ADR-018 raw CSI frame (magic 0xC511_0001).
|
||||
///
|
||||
/// Header layout (authoritative: firmware `csi_collector.c` / ADR-018):
|
||||
/// magic u32 LE @0, node_id u8 @4, n_antennas u8 @5, n_subcarriers u16 LE
|
||||
/// @6-7, freq_mhz u32 LE @8-11, sequence u32 LE @12-15, rssi i8 @16,
|
||||
/// noise_floor i8 @17, PPDU type u8 @18 (ADR-110), flags u8 @19 (ADR-110),
|
||||
/// I/Q pairs from @20.
|
||||
///
|
||||
/// Until issue #1005 this function read `n_subcarriers` from byte 6 alone
|
||||
/// (an ESP32-C6 HE-SU frame's 256 = 0x0100 LE decoded as 0 — the frame
|
||||
/// parsed "successfully" with zero subcarriers) and read sequence/rssi/
|
||||
/// noise at stale offsets 10/14/15 (rssi landed on sequence bytes ⇒ 0).
|
||||
pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
if buf.len() < 20 {
|
||||
return None;
|
||||
@@ -95,16 +108,18 @@ pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
|
||||
let node_id = buf[4];
|
||||
let n_antennas = buf[5];
|
||||
let n_subcarriers = buf[6];
|
||||
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
|
||||
let sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
|
||||
let rssi_raw = buf[14] as i8;
|
||||
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]);
|
||||
let freq_mhz_u32 = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
|
||||
let freq_mhz = u16::try_from(freq_mhz_u32).unwrap_or(0);
|
||||
let sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
|
||||
let rssi_raw = buf[16] as i8;
|
||||
let rssi = if rssi_raw > 0 {
|
||||
rssi_raw.saturating_neg()
|
||||
} else {
|
||||
rssi_raw
|
||||
};
|
||||
let noise_floor = buf[15] as i8;
|
||||
let noise_floor = buf[17] as i8;
|
||||
let ppdu_type = PpduType::from_byte(buf[18]);
|
||||
|
||||
let iq_start = 20;
|
||||
let n_pairs = n_antennas as usize * n_subcarriers as usize;
|
||||
@@ -131,6 +146,7 @@ pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
sequence,
|
||||
rssi,
|
||||
noise_floor,
|
||||
ppdu_type,
|
||||
amplitudes,
|
||||
phases,
|
||||
})
|
||||
@@ -964,11 +980,12 @@ pub fn generate_simulated_frame(tick: u64) -> Esp32Frame {
|
||||
magic: 0xC511_0001,
|
||||
node_id: 1,
|
||||
n_antennas: 1,
|
||||
n_subcarriers: n_sub as u8,
|
||||
n_subcarriers: n_sub as u16,
|
||||
freq_mhz: 2437,
|
||||
sequence: tick as u32,
|
||||
rssi: (-40.0 + 5.0 * (t * 0.2).sin()) as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: PpduType::HtLegacy,
|
||||
amplitudes,
|
||||
phases,
|
||||
}
|
||||
@@ -981,3 +998,76 @@ pub fn chrono_timestamp() -> u64 {
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ── ADR-110 / issue #1005 tests: live ESP32-C6 HE-LTF frames ────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod adr110_tests {
|
||||
use super::*;
|
||||
use crate::types::NodeState;
|
||||
|
||||
/// Verbatim 532-byte HE-SU UDP payload captured live 2026-06-11 from an
|
||||
/// ESP32-C6 (node 12, IDF v5.5): 256 subcarrier bins, byte18=0x01.
|
||||
const HE_FRAME_HEX: &str = "010011c50c010001800900005a2d0000d8a9011000000000000000000000f70ef70ef50cf30bf209f108f006ef03ee02ee00eefdeffbeff8f0f7f1f4f2f3f4f1f5f0f7eef8edfaecfdecffeb01ea03ea05e908ea0aeb0deb0fec11ee13f015f216f318f519f71afa1bfd1bff1c021c051b071b0a1a0c190f1811161315161218101a0e1b0c1c091d071e041f0120ff20fc20f91ff71ff41ef11def1cec1be919e717e615e413e311e10edf0cde09dd06dc04dc01dcffdcfbdcf9ddf6def3dff0e0ede2eae4e8e6e6e8e4eae2ebe0eedef1dcf4dbf7dafad9fdd900d903d806d909d90cda0fdc12dc14dd17df1ae11ce31ee520e722e924ed25f127f328f629f929fd2900290329062809270c260e26122516061a00001c201c1f1a211722142411250e260c27082804280129fe29fb28f927f627f426f125ef23ec22ea20e81eea20e81e891b53a82951565d4ffafbfebe9abddb10222aa47b3b371fd2c0860cd4d86ea2f35faccd46b0b66f6ff0050f2da27d1c92f7f8e1017cb545afd3e3fe60db6f478dc85a33b3454cf6df9061194a0a0fc3e0eedf76f1d292cb25c8f541dfcc4109f9f1a34955520ad8ffa3694ac395cbf6c19073a4aefb1ebf47c76730458431805d9f18ff2e81955e8752b29757f66e289f72f8e35309a737547c040444cbda1a81d221d950037ec38fd9d1dd0f56c3dc707a7bbfe66ca5a97ab7cc17d68d38ba43a1806f91f5911a5967e2c9f7f07186";
|
||||
|
||||
/// Verbatim 148-byte HT payload from the same node seconds later:
|
||||
/// 64 bins, byte18=0x00.
|
||||
const HT_FRAME_HEX: &str = "010011c50c01400080090000662d0000b1a900100000000000000000fcfaf909f013f112f213f212f311f410f511f510f610f510f411f410f411f312f213f214f214f212f313f513f512f611f610f80ef90df90c0000010eff11fe13ff11fe1300000000ff01000001010002000200020204000301040103000400040002ff03ff03fe02fe02fe01fd00edfc03fa000000000000";
|
||||
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_he_su_frame_parses_with_256_subcarriers() {
|
||||
let buf = unhex(HE_FRAME_HEX);
|
||||
assert_eq!(buf.len(), 532);
|
||||
let f = parse_esp32_frame(&buf).expect("532-byte HE frame must parse");
|
||||
assert_eq!(f.node_id, 12);
|
||||
assert_eq!(f.n_subcarriers, 256);
|
||||
assert_eq!(f.amplitudes.len(), 256);
|
||||
assert_eq!(f.freq_mhz, 2432);
|
||||
assert_eq!(f.sequence, 11610);
|
||||
assert_eq!(f.rssi, -40);
|
||||
assert_eq!(f.noise_floor, -87);
|
||||
assert_eq!(f.ppdu_type, PpduType::HeSu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_ht_frame_parses_with_64_subcarriers() {
|
||||
let buf = unhex(HT_FRAME_HEX);
|
||||
assert_eq!(buf.len(), 148);
|
||||
let f = parse_esp32_frame(&buf).expect("148-byte HT frame must parse");
|
||||
assert_eq!(f.node_id, 12);
|
||||
assert_eq!(f.n_subcarriers, 64);
|
||||
assert_eq!(f.amplitudes.len(), 64);
|
||||
assert_eq!(f.rssi, -79);
|
||||
assert_eq!(f.ppdu_type, PpduType::HtLegacy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_gate_never_mixes_ht_and_he_windows() {
|
||||
let he = parse_esp32_frame(&unhex(HE_FRAME_HEX)).unwrap();
|
||||
let ht = parse_esp32_frame(&unhex(HT_FRAME_HEX)).unwrap();
|
||||
let mut ns = NodeState::new();
|
||||
|
||||
// First frame locks the grid.
|
||||
assert!(ns.accept_grid(ht.grid()));
|
||||
ns.frame_history.push_back(ht.amplitudes.clone());
|
||||
|
||||
// HE upgrade: accepted, denser grid wins, history re-keyed.
|
||||
assert!(ns.accept_grid(he.grid()));
|
||||
assert!(ns.frame_history.is_empty(), "upgrade must clear HT history");
|
||||
ns.frame_history.push_back(he.amplitudes.clone());
|
||||
|
||||
// Interleaved HT minority frames are rejected from the feature path.
|
||||
assert!(!ns.accept_grid(ht.grid()));
|
||||
assert_eq!(ns.frame_history.len(), 1, "HT frame must not touch window");
|
||||
|
||||
// Steady-state HE frames keep flowing.
|
||||
assert!(ns.accept_grid(he.grid()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,15 +226,28 @@ struct Esp32Frame {
|
||||
magic: u32,
|
||||
node_id: u8,
|
||||
n_antennas: u8,
|
||||
n_subcarriers: u8,
|
||||
/// u16 since ADR-110 / issue #1005: ESP32-C6 HE-SU frames carry 256
|
||||
/// subcarrier bins (242 active HE20 tones). HT frames stay ≤128.
|
||||
n_subcarriers: u16,
|
||||
freq_mhz: u16,
|
||||
sequence: u32,
|
||||
rssi: i8,
|
||||
noise_floor: i8,
|
||||
/// ADR-110 byte 18: PPDU type the CSI was sampled from. Pre-ADR-110
|
||||
/// firmware sends 0 ⇒ `PpduType::HtLegacy`.
|
||||
ppdu_type: wifi_densepose_hardware::PpduType,
|
||||
amplitudes: Vec<f64>,
|
||||
phases: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Esp32Frame {
|
||||
/// The `(n_subcarriers, ppdu_type)` symbol-grid identity of this frame.
|
||||
/// HT-LTF and HE-LTF grids are not bin-comparable (ADR-110 / #1005).
|
||||
fn grid(&self) -> (u16, wifi_densepose_hardware::PpduType) {
|
||||
(self.n_subcarriers, self.ppdu_type)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sensing update broadcast to WebSocket clients
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SensingUpdate {
|
||||
@@ -442,6 +455,12 @@ struct NodeState {
|
||||
/// Most recent novelty score in [0.0, 1.0] (0 = exact-match in bank,
|
||||
/// 1 = no overlap). Consumed by the model-wake gate downstream.
|
||||
pub(crate) last_novelty_score: Option<f32>,
|
||||
/// ADR-110 / issue #1005: the `(n_subcarriers, ppdu_type)` grid this
|
||||
/// node's rolling windows were built on. ESP32-C6 nodes interleave
|
||||
/// HE-SU 256-bin frames with HT 64-bin frames on one socket; mixing
|
||||
/// the two symbol grids in `frame_history` corrupts variance/baseline
|
||||
/// statistics. See [`NodeState::accept_grid`].
|
||||
active_grid: Option<(u16, wifi_densepose_hardware::PpduType)>,
|
||||
}
|
||||
|
||||
/// Default EMA alpha for temporal keypoint smoothing (RuVector Phase 2).
|
||||
@@ -647,6 +666,35 @@ impl NodeState {
|
||||
),
|
||||
),
|
||||
last_novelty_score: None,
|
||||
active_grid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR-110 / issue #1005 grid gate: decide whether a frame on `grid`
|
||||
/// may enter this node's feature path, and update `active_grid`.
|
||||
///
|
||||
/// Returns `true` to accept. Policy: lock onto the densest grid seen.
|
||||
/// On a grid *upgrade* (more subcarriers — e.g. the first HE-SU 256-bin
|
||||
/// frame after HT 64-bin history) the rolling amplitude history and
|
||||
/// motion baseline are cleared so HT and HE symbol grids are never
|
||||
/// mixed in one window. Sparser-grid frames (the ~16% HT minority an
|
||||
/// ESP32-C6 keeps emitting alongside HE) are rejected from the feature
|
||||
/// path; the caller still records the arrival for fps/liveness.
|
||||
fn accept_grid(&mut self, grid: (u16, wifi_densepose_hardware::PpduType)) -> bool {
|
||||
match self.active_grid {
|
||||
None => {
|
||||
self.active_grid = Some(grid);
|
||||
true
|
||||
}
|
||||
Some(active) if active == grid => true,
|
||||
Some((active_n, _)) if grid.0 > active_n => {
|
||||
self.active_grid = Some(grid);
|
||||
self.frame_history.clear();
|
||||
self.baseline_motion = 0.0;
|
||||
self.baseline_frames = 0;
|
||||
true
|
||||
}
|
||||
Some(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1374,19 +1422,25 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
// [17] noise_floor (i8)
|
||||
// [18..19] reserved
|
||||
// [20..] I/Q data
|
||||
// Issue #1005: until 2026-06 this code read n_subcarriers from byte 6
|
||||
// alone (an ESP32-C6 HE-SU frame's 256 = 0x0100 LE decoded as 0 — the
|
||||
// frame parsed with zero subcarriers) and read sequence/rssi/noise at
|
||||
// stale offsets 10/14/15. Offsets below match the comment (and firmware).
|
||||
let node_id = buf[4];
|
||||
let n_antennas = buf[5];
|
||||
let n_subcarriers = buf[6];
|
||||
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
|
||||
let sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
|
||||
let rssi_raw = buf[14] as i8;
|
||||
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]);
|
||||
let freq_mhz =
|
||||
u16::try_from(u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]])).unwrap_or(0);
|
||||
let sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
|
||||
let rssi_raw = buf[16] as i8;
|
||||
// Fix RSSI sign: ensure it's always negative (dBm convention).
|
||||
let rssi = if rssi_raw > 0 {
|
||||
rssi_raw.saturating_neg()
|
||||
} else {
|
||||
rssi_raw
|
||||
};
|
||||
let noise_floor = buf[15] as i8;
|
||||
let noise_floor = buf[17] as i8;
|
||||
let ppdu_type = wifi_densepose_hardware::PpduType::from_byte(buf[18]);
|
||||
|
||||
let iq_start = 20;
|
||||
let n_pairs = n_antennas as usize * n_subcarriers as usize;
|
||||
@@ -1415,6 +1469,7 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
sequence,
|
||||
rssi,
|
||||
noise_floor,
|
||||
ppdu_type,
|
||||
amplitudes,
|
||||
phases,
|
||||
})
|
||||
@@ -2296,11 +2351,12 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
||||
magic: 0xC511_0001,
|
||||
node_id: 0,
|
||||
n_antennas: 1,
|
||||
n_subcarriers: obs_count.min(255) as u8,
|
||||
n_subcarriers: obs_count.min(u16::MAX as usize) as u16,
|
||||
freq_mhz: 2437,
|
||||
sequence: seq,
|
||||
rssi: first_rssi.clamp(-128.0, 127.0) as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
||||
amplitudes: multi_ap_frame.amplitudes.clone(),
|
||||
phases: multi_ap_frame.phases.clone(),
|
||||
};
|
||||
@@ -2482,6 +2538,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
|
||||
sequence: seq,
|
||||
rssi: rssi_dbm as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
||||
amplitudes: vec![signal_pct],
|
||||
phases: vec![0.0],
|
||||
};
|
||||
@@ -2615,7 +2672,11 @@ async fn probe_esp32(port: u16) -> bool {
|
||||
let addr = format!("0.0.0.0:{port}");
|
||||
match UdpSocket::bind(&addr).await {
|
||||
Ok(sock) => {
|
||||
let mut buf = [0u8; 256];
|
||||
// 2048 covers the largest ADR-018 frame: an ESP32-C6 HE-SU
|
||||
// capture is 532 bytes (issue #1005); on Windows a too-small
|
||||
// recv buffer makes recv_from error on the oversized datagram,
|
||||
// which made this probe fail against HE-only streams.
|
||||
let mut buf = [0u8; 2048];
|
||||
match tokio::time::timeout(Duration::from_secs(2), sock.recv_from(&mut buf)).await {
|
||||
Ok(Ok((len, _))) => parse_esp32_frame(&buf[..len]).is_some(),
|
||||
_ => false,
|
||||
@@ -2644,11 +2705,12 @@ fn generate_simulated_frame(tick: u64) -> Esp32Frame {
|
||||
magic: 0xC511_0001,
|
||||
node_id: 1,
|
||||
n_antennas: 1,
|
||||
n_subcarriers: n_sub as u8,
|
||||
n_subcarriers: n_sub as u16,
|
||||
freq_mhz: 2437,
|
||||
sequence: tick as u32,
|
||||
rssi: (-40.0 + 5.0 * (t * 0.2).sin()) as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
||||
amplitudes,
|
||||
phases,
|
||||
}
|
||||
@@ -5231,6 +5293,34 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
s.source = "esp32".to_string();
|
||||
s.last_esp32_frame = Some(std::time::Instant::now());
|
||||
|
||||
// ── ADR-110 / issue #1005: per-node subcarrier-grid gate ──
|
||||
// ESP32-C6 nodes interleave HE-SU 256-bin frames (~84%)
|
||||
// with HT 64-bin frames on the same socket. HT-LTF and
|
||||
// HE-LTF symbol grids are not bin-comparable, so a frame
|
||||
// on a different grid than the node's rolling window must
|
||||
// not enter the feature path. Policy (NodeState::accept_grid):
|
||||
// lock onto the densest grid seen, clear+re-warm on
|
||||
// upgrade, skip sparser-grid frames (arrival still
|
||||
// recorded for fps/liveness).
|
||||
let grid_accepted = s
|
||||
.node_states
|
||||
.entry(frame.node_id)
|
||||
.or_insert_with(NodeState::new)
|
||||
.accept_grid(frame.grid());
|
||||
if !grid_accepted {
|
||||
debug!(
|
||||
"node {}: skipping {}-subcarrier {:?} frame (active grid {:?})",
|
||||
frame.node_id,
|
||||
frame.n_subcarriers,
|
||||
frame.ppdu_type,
|
||||
s.node_states.get(&frame.node_id).and_then(|ns| ns.active_grid),
|
||||
);
|
||||
if let Some(ns) = s.node_states.get_mut(&frame.node_id) {
|
||||
ns.observe_csi_frame_arrival(std::time::Instant::now());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Also maintain global frame_history for backward compat
|
||||
// (simulation path, REST endpoints, etc.).
|
||||
s.frame_history.push_back(frame.amplitudes.clone());
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::rvf_container::RvfContainerInfo;
|
||||
use crate::rvf_pipeline::ProgressiveLoader;
|
||||
use crate::vital_signs::{VitalSignDetector, VitalSigns};
|
||||
|
||||
use wifi_densepose_hardware::PpduType;
|
||||
use wifi_densepose_signal::ruvsense::field_model::FieldModel;
|
||||
use wifi_densepose_signal::ruvsense::longitudinal::{EmbeddingEntry, EmbeddingHistory};
|
||||
use wifi_densepose_signal::ruvsense::multistatic::MultistaticFuser;
|
||||
@@ -84,15 +85,33 @@ pub struct Esp32Frame {
|
||||
pub magic: u32,
|
||||
pub node_id: u8,
|
||||
pub n_antennas: u8,
|
||||
pub n_subcarriers: u8,
|
||||
/// Subcarrier bin count. u16 since ADR-110: ESP32-C6 HE-LTF frames carry
|
||||
/// 256 bins (242 active HE20 tones) — issue #1005. HT frames stay ≤128.
|
||||
pub n_subcarriers: u16,
|
||||
pub freq_mhz: u16,
|
||||
pub sequence: u32,
|
||||
pub rssi: i8,
|
||||
pub noise_floor: i8,
|
||||
/// ADR-110 byte 18: PPDU type the CSI was sampled from (HT-LTF vs
|
||||
/// HE-LTF symbol grids are NOT comparable bin-for-bin). Pre-ADR-110
|
||||
/// firmware sends 0 ⇒ `PpduType::HtLegacy`.
|
||||
pub ppdu_type: PpduType,
|
||||
pub amplitudes: Vec<f64>,
|
||||
pub phases: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Esp32Frame {
|
||||
/// The (subcarrier-count, PPDU-type) pair identifying which symbol grid
|
||||
/// this frame was sampled on. Frames from different grids must never be
|
||||
/// mixed in one rolling baseline window (ADR-110 / issue #1005).
|
||||
pub fn grid(&self) -> CsiGrid {
|
||||
(self.n_subcarriers, self.ppdu_type)
|
||||
}
|
||||
}
|
||||
|
||||
/// Subcarrier-grid identity: `(n_subcarriers, ppdu_type)`.
|
||||
pub type CsiGrid = (u16, PpduType);
|
||||
|
||||
// ── Sensing Update ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Sensing update broadcast to WebSocket clients
|
||||
@@ -281,6 +300,14 @@ pub struct NodeState {
|
||||
/// `None` until the first `update_novelty` call. Consumed by the
|
||||
/// model-wake gate downstream (low novelty → skip CNN, save energy).
|
||||
pub last_novelty_score: Option<f32>,
|
||||
/// ADR-110 / issue #1005: the `(n_subcarriers, ppdu_type)` grid this
|
||||
/// node's rolling windows were built on. ESP32-C6 nodes interleave
|
||||
/// HE-SU 256-bin frames with HT 64-bin frames on one socket; mixing
|
||||
/// the two symbol grids in `frame_history` corrupts variance/baseline
|
||||
/// statistics. Policy: lock onto the densest grid seen; frames on a
|
||||
/// sparser grid are counted as arrivals but skipped by the feature
|
||||
/// path; a grid upgrade clears the history and re-warms the baseline.
|
||||
pub active_grid: Option<CsiGrid>,
|
||||
}
|
||||
|
||||
impl Default for NodeState {
|
||||
@@ -322,6 +349,35 @@ impl NodeState {
|
||||
NOVELTY_SKETCH_VERSION,
|
||||
)),
|
||||
last_novelty_score: None,
|
||||
active_grid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR-110 / issue #1005 grid gate: decide whether a frame on `grid`
|
||||
/// may enter this node's feature path, and update `active_grid`.
|
||||
///
|
||||
/// Returns `true` to accept. On a grid *upgrade* (more subcarriers than
|
||||
/// the current grid — e.g. first HE-SU 256-bin frame after HT 64-bin
|
||||
/// history) the rolling amplitude history and motion baseline are
|
||||
/// cleared so HT and HE symbol grids are never mixed in one window.
|
||||
/// Sparser-grid frames (the ~16% HT minority a C6 keeps emitting) are
|
||||
/// rejected from the feature path.
|
||||
pub fn accept_grid(&mut self, grid: CsiGrid) -> bool {
|
||||
match self.active_grid {
|
||||
None => {
|
||||
self.active_grid = Some(grid);
|
||||
true
|
||||
}
|
||||
Some(active) if active == grid => true,
|
||||
Some((active_n, _)) if grid.0 > active_n => {
|
||||
// Denser grid wins: re-key the window and re-warm baselines.
|
||||
self.active_grid = Some(grid);
|
||||
self.frame_history.clear();
|
||||
self.baseline_motion = 0.0;
|
||||
self.baseline_frames = 0;
|
||||
true
|
||||
}
|
||||
Some(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,19 +13,19 @@ use std::time::Duration;
|
||||
|
||||
/// Build a minimal valid ESP32 CSI frame (magic 0xC511_0001).
|
||||
///
|
||||
/// Format (ADR-018):
|
||||
/// [0..3] magic: 0xC511_0001 (LE)
|
||||
/// [4] node_id
|
||||
/// [5] n_antennas (1)
|
||||
/// [6] n_subcarriers (e.g., 32)
|
||||
/// [7] reserved
|
||||
/// [8..9] freq_mhz (2437 = channel 6)
|
||||
/// [10..13] sequence (LE u32)
|
||||
/// [14] rssi (signed)
|
||||
/// [15] noise_floor
|
||||
/// [16..19] reserved
|
||||
/// [20..] I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
|
||||
fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u8) -> Vec<u8> {
|
||||
/// Format (ADR-018, authoritative: firmware `csi_collector.c`):
|
||||
/// [0..3] magic: 0xC511_0001 (LE)
|
||||
/// [4] node_id
|
||||
/// [5] n_antennas (1)
|
||||
/// [6..7] n_subcarriers (LE u16 — 256 for ESP32-C6 HE-SU, issue #1005)
|
||||
/// [8..11] freq_mhz (LE u32, 2437 = channel 6)
|
||||
/// [12..15] sequence (LE u32)
|
||||
/// [16] rssi (signed)
|
||||
/// [17] noise_floor
|
||||
/// [18] PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU)
|
||||
/// [19] flags (ADR-110)
|
||||
/// [20..] I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
|
||||
fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u16) -> Vec<u8> {
|
||||
let n_pairs = n_sub as usize;
|
||||
let mut buf = vec![0u8; 20 + n_pairs * 2];
|
||||
|
||||
@@ -35,18 +35,19 @@ fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u8) -> Vec<u8> {
|
||||
|
||||
buf[4] = node_id;
|
||||
buf[5] = 1; // n_antennas
|
||||
buf[6] = n_sub;
|
||||
buf[7] = 0;
|
||||
buf[6..8].copy_from_slice(&n_sub.to_le_bytes());
|
||||
|
||||
// freq = 2437 MHz (channel 6)
|
||||
let freq: u16 = 2437;
|
||||
buf[8..10].copy_from_slice(&freq.to_le_bytes());
|
||||
let freq: u32 = 2437;
|
||||
buf[8..12].copy_from_slice(&freq.to_le_bytes());
|
||||
|
||||
// sequence
|
||||
buf[10..14].copy_from_slice(&seq.to_le_bytes());
|
||||
buf[12..16].copy_from_slice(&seq.to_le_bytes());
|
||||
|
||||
buf[14] = rssi as u8;
|
||||
buf[15] = (-90i8) as u8; // noise floor
|
||||
buf[16] = rssi as u8;
|
||||
buf[17] = (-90i8) as u8; // noise floor
|
||||
buf[18] = u8::from(n_sub >= 256); // ADR-110 PPDU type: HE-SU for 256-bin
|
||||
buf[19] = 0; // ADR-110 flags
|
||||
|
||||
// Generate I/Q pairs with node-specific patterns.
|
||||
// Different nodes produce different amplitude patterns so the server
|
||||
@@ -136,7 +137,7 @@ fn test_multi_node_udp_send() {
|
||||
sock.set_write_timeout(Some(Duration::from_millis(100)))
|
||||
.ok();
|
||||
|
||||
let n_sub = 32u8;
|
||||
let n_sub = 32u16;
|
||||
let node_ids = [1u8, 2, 3, 5, 7];
|
||||
|
||||
for &nid in &node_ids {
|
||||
@@ -161,11 +162,13 @@ fn test_multi_node_udp_send() {
|
||||
/// size for various subcarrier counts (boundary testing).
|
||||
#[test]
|
||||
fn test_frame_sizes() {
|
||||
for n_sub in [1u8, 16, 32, 52, 56, 64, 128] {
|
||||
// 256 = ESP32-C6 HE-SU grid (issue #1005) → 532-byte frame as on the wire.
|
||||
for n_sub in [1u16, 16, 32, 52, 56, 64, 128, 256] {
|
||||
let frame = build_csi_frame(1, 0, -50, n_sub);
|
||||
let expected = 20 + (n_sub as usize) * 2;
|
||||
assert_eq!(frame.len(), expected, "wrong size for n_sub={n_sub}");
|
||||
}
|
||||
assert_eq!(build_csi_frame(1, 0, -50, 256).len(), 532);
|
||||
}
|
||||
|
||||
/// Simulate a mesh of N nodes sending frames at different rates.
|
||||
|
||||
Reference in New Issue
Block a user