feat(rust): host-side decode for ADR-018 byte 18-19 (ADR-110 closure)

Parse the C6 firmware's HE PPDU type + bandwidth/flags from ADR-018
bytes 18-19 (previously discarded as _reserved). Adds two types to
CsiMetadata: ppdu_type (HtLegacy/HeSu/HeMu/HeTb/Unknown) and
adr018_flags (bw40/stbc/ldpc/ieee802154_sync_valid).

Pre-ADR-110 firmware sends zeros which round-trip as HtLegacy +
default flags — fully backwards compatible.

6 new deterministic unit tests:
- Pre-ADR-110 backwards compat
- HE-SU / HE-MU / HE-TB decode
- Unknown PPDU byte -> Unknown
- All-bits-set flags round-trip
- PpduType byte round-trip

Result: 122 wifi-densepose-hardware tests pass, 0 fail. Host decoder
now matches the firmware encoder bit-for-bit — HE-LTF metadata path
works end-to-end the moment an 11ax AP is in range.

Ref: ruvnet/RuView#762

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-22 22:42:49 -04:00
parent 88be283ab0
commit 3959fabf31
3 changed files with 187 additions and 16 deletions

View File

@ -105,6 +105,8 @@ mod tests {
rx_antennas: n_antennas,
},
sequence: 42,
ppdu_type: crate::csi_frame::PpduType::HtLegacy,
adr018_flags: crate::csi_frame::Adr018Flags::default(),
},
subcarriers,
}

View File

@ -80,6 +80,98 @@ pub struct CsiMetadata {
pub antenna_config: AntennaConfig,
/// Sequence number for ordering
pub sequence: u32,
/// ADR-110: PPDU type from ADR-018 byte 18. None on pre-ADR-110 firmware
/// (or when CONFIG_CSI_FRAME_HE_TAGGING is disabled — byte stays zero
/// and pre-ADR-110 readers see the same zero, full backwards compat).
/// Byte 18 = 0 reads as PpduType::HtLegacy (the wire encoding for the
/// HT/legacy bucket); 0xFF reads as PpduType::Unknown.
pub ppdu_type: PpduType,
/// ADR-110: flags from ADR-018 byte 19 — bandwidth bits, STBC, LDPC,
/// 802.15.4-time-sync-valid bit. See [`Adr018Flags`].
pub adr018_flags: Adr018Flags,
}
/// PPDU type encoded in ADR-018 byte 18 (ADR-110 extension).
///
/// Wire encoding (matches firmware `csi_collector.c`):
/// 0 = HT / legacy bucket (11b/g/HT/VHT all collapse here)
/// 1 = HE-SU (802.11ax single-user)
/// 2 = HE-MU (802.11ax multi-user)
/// 3 = HE-TB (802.11ax trigger-based)
/// 0xFF = Unknown
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PpduType {
HtLegacy,
HeSu,
HeMu,
HeTb,
Unknown,
}
impl PpduType {
pub fn from_byte(b: u8) -> Self {
match b {
0 => Self::HtLegacy,
1 => Self::HeSu,
2 => Self::HeMu,
3 => Self::HeTb,
_ => Self::Unknown,
}
}
pub fn to_byte(self) -> u8 {
match self {
Self::HtLegacy => 0,
Self::HeSu => 1,
Self::HeMu => 2,
Self::HeTb => 3,
Self::Unknown => 0xFF,
}
}
pub fn is_he(self) -> bool {
matches!(self, Self::HeSu | Self::HeMu | Self::HeTb)
}
}
/// Flags encoded in ADR-018 byte 19 (ADR-110 extension).
///
/// Wire encoding:
/// bit 0 : bandwidth wide (set = 40 MHz, clear = 20 MHz)
/// bit 1 : (reserved for 80/160 future)
/// bit 2 : STBC
/// bit 3 : LDPC (reserved — not yet populated by firmware)
/// bit 4 : 802.15.4 time-sync valid (C6 only)
/// bit 5-7 : reserved
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Adr018Flags {
pub bw40: bool,
pub stbc: bool,
pub ldpc: bool,
pub ieee802154_sync_valid: bool,
}
impl Adr018Flags {
pub fn from_byte(b: u8) -> Self {
Self {
bw40: (b & 0x01) != 0,
stbc: (b & 0x04) != 0,
ldpc: (b & 0x08) != 0,
ieee802154_sync_valid: (b & 0x10) != 0,
}
}
pub fn to_byte(self) -> u8 {
let mut b = 0u8;
if self.bw40 { b |= 0x01; }
if self.stbc { b |= 0x04; }
if self.ldpc { b |= 0x08; }
if self.ieee802154_sync_valid { b |= 0x10; }
b
}
}
impl Default for Adr018Flags {
fn default() -> Self {
Self { bw40: false, stbc: false, ldpc: false, ieee802154_sync_valid: false }
}
}
/// WiFi channel bandwidth.
@ -154,6 +246,8 @@ mod tests {
bandwidth: Bandwidth::Bw20,
antenna_config: AntennaConfig::default(),
sequence: 1,
ppdu_type: PpduType::HtLegacy,
adr018_flags: Adr018Flags::default(),
},
subcarriers: vec![
SubcarrierData { i: 100, q: 0, index: -28 },

View File

@ -31,7 +31,9 @@ use byteorder::{LittleEndian, ReadBytesExt};
use chrono::Utc;
use std::io::Cursor;
use crate::csi_frame::{AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, SubcarrierData};
use crate::csi_frame::{
Adr018Flags, AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, PpduType, SubcarrierData,
};
use crate::error::ParseError;
/// ESP32 CSI binary frame magic number (ADR-018).
@ -178,11 +180,20 @@ impl Esp32CsiParser {
message: "Failed to read noise floor".into(),
})?;
// Reserved (offset 18, 2 bytes) — skip
let _reserved = cursor.read_u16::<LittleEndian>().map_err(|_| ParseError::ByteError {
// ADR-110: bytes 18-19 carry PPDU type + flags (previously reserved-zero,
// now opt-in via CONFIG_CSI_FRAME_HE_TAGGING in firmware). Pre-ADR-110
// firmware sends zeros, which round-trip as PpduType::HtLegacy +
// Adr018Flags::default() — fully backwards compatible.
let ppdu_byte = cursor.read_u8().map_err(|_| ParseError::ByteError {
offset: 18,
message: "Failed to read reserved bytes".into(),
message: "Failed to read PPDU type byte".into(),
})?;
let flags_byte = cursor.read_u8().map_err(|_| ParseError::ByteError {
offset: 19,
message: "Failed to read flags byte".into(),
})?;
let ppdu_type = PpduType::from_byte(ppdu_byte);
let adr018_flags = Adr018Flags::from_byte(flags_byte);
// I/Q data: n_antennas * n_subcarriers * 2 bytes
let iq_pair_count = n_antennas as usize * n_subcarriers;
@ -245,6 +256,8 @@ impl Esp32CsiParser {
rx_antennas: n_antennas,
},
sequence,
ppdu_type,
adr018_flags,
},
subcarriers,
};
@ -293,7 +306,20 @@ mod tests {
use super::*;
/// Build a valid ADR-018 ESP32 CSI frame with known parameters.
/// PPDU type + flags bytes (offset 18-19) are zero — pre-ADR-110 default,
/// which round-trips as PpduType::HtLegacy + Adr018Flags::default().
fn build_test_frame(node_id: u8, n_antennas: u8, subcarrier_pairs: &[(i8, i8)]) -> Vec<u8> {
build_test_frame_with_he(node_id, n_antennas, subcarrier_pairs, 0, 0)
}
/// ADR-110-aware variant: explicit byte 18 (PPDU type) and byte 19 (flags).
fn build_test_frame_with_he(
node_id: u8,
n_antennas: u8,
subcarrier_pairs: &[(i8, i8)],
ppdu_byte: u8,
flags_byte: u8,
) -> Vec<u8> {
let n_subcarriers = if n_antennas == 0 {
subcarrier_pairs.len()
} else {
@ -301,26 +327,16 @@ mod tests {
};
let mut buf = Vec::new();
// Magic (offset 0)
buf.extend_from_slice(&ESP32_CSI_MAGIC.to_le_bytes());
// Node ID (offset 4)
buf.push(node_id);
// Number of antennas (offset 5)
buf.push(n_antennas);
// Number of subcarriers (offset 6, LE u16)
buf.extend_from_slice(&(n_subcarriers as u16).to_le_bytes());
// Frequency MHz (offset 8, LE u32)
buf.extend_from_slice(&2437u32.to_le_bytes());
// Sequence number (offset 12, LE u32)
buf.extend_from_slice(&1u32.to_le_bytes());
// RSSI (offset 16, i8)
buf.push((-50i8) as u8);
// Noise floor (offset 17, i8)
buf.push((-95i8) as u8);
// Reserved (offset 18, 2 bytes)
buf.extend_from_slice(&[0u8; 2]);
// I/Q data (offset 20)
buf.push(ppdu_byte);
buf.push(flags_byte);
for (i, q) in subcarrier_pairs {
buf.push(*i as u8);
buf.push(*q as u8);
@ -329,6 +345,65 @@ mod tests {
buf
}
// ── ADR-110: byte 18-19 round-trip tests ─────────────────────────────────
#[test]
fn adr110_pre_adr110_firmware_round_trips_as_ht_legacy_default_flags() {
// Pre-ADR-110 firmware writes zeros to bytes 18-19. The parser must
// surface that as HtLegacy + default flags so old aggregators see
// identical behavior to before the extension.
let data = build_test_frame(1, 1, &[(0, 0); 56]);
let (frame, _) = Esp32CsiParser::parse_frame(&data).unwrap();
assert_eq!(frame.metadata.ppdu_type, PpduType::HtLegacy);
assert_eq!(frame.metadata.adr018_flags, Adr018Flags::default());
assert!(!frame.metadata.ppdu_type.is_he());
}
#[test]
fn adr110_he_su_ppdu_decodes() {
let data = build_test_frame_with_he(2, 1, &[(0, 0); 56], /*PPDU*/ 1, /*flags*/ 0);
let (frame, _) = Esp32CsiParser::parse_frame(&data).unwrap();
assert_eq!(frame.metadata.ppdu_type, PpduType::HeSu);
assert!(frame.metadata.ppdu_type.is_he());
}
#[test]
fn adr110_he_mu_he_tb_decode() {
let mu = build_test_frame_with_he(3, 1, &[(0, 0); 56], 2, 0);
let tb = build_test_frame_with_he(4, 1, &[(0, 0); 56], 3, 0);
let (mu_frame, _) = Esp32CsiParser::parse_frame(&mu).unwrap();
let (tb_frame, _) = Esp32CsiParser::parse_frame(&tb).unwrap();
assert_eq!(mu_frame.metadata.ppdu_type, PpduType::HeMu);
assert_eq!(tb_frame.metadata.ppdu_type, PpduType::HeTb);
}
#[test]
fn adr110_unknown_ppdu_byte_decodes_as_unknown() {
let data = build_test_frame_with_he(5, 1, &[(0, 0); 56], 0xFF, 0);
let (frame, _) = Esp32CsiParser::parse_frame(&data).unwrap();
assert_eq!(frame.metadata.ppdu_type, PpduType::Unknown);
}
#[test]
fn adr110_flags_round_trip_all_bits() {
// All known flag bits set: bw40 (0x01) + STBC (0x04) + LDPC (0x08) + 15.4-sync (0x10) = 0x1D
let data = build_test_frame_with_he(6, 1, &[(0, 0); 56], 1, 0x1D);
let (frame, _) = Esp32CsiParser::parse_frame(&data).unwrap();
assert!(frame.metadata.adr018_flags.bw40);
assert!(frame.metadata.adr018_flags.stbc);
assert!(frame.metadata.adr018_flags.ldpc);
assert!(frame.metadata.adr018_flags.ieee802154_sync_valid);
// Round-trip the encoder
assert_eq!(frame.metadata.adr018_flags.to_byte(), 0x1D);
}
#[test]
fn adr110_ppdu_byte_round_trips_for_known_variants() {
for v in [PpduType::HtLegacy, PpduType::HeSu, PpduType::HeMu, PpduType::HeTb, PpduType::Unknown] {
assert_eq!(PpduType::from_byte(v.to_byte()), v, "round-trip failed for {v:?}");
}
}
#[test]
fn test_parse_valid_frame() {
// 1 antenna, 56 subcarriers