mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-21 22:22:33 -05:00
feat(bfld): VHT compressed-beamforming-report decoder (ADR-365)
The BFLD crate consumed beamforming feedback only as an opaque `compressed_angle_matrix` blob; nothing decoded a real 802.11 Compressed Beamforming Report. Adds a pure-Rust `cbr` module: - VHT MIMO Control parse (Nc/Nr/width/grouping/codebook/SU-MU/token) - Givens angle-count + Table 9-91 subcarrier + Table 9-92 codebook widths - LSB-first bitstream unpack to per-subcarrier Phi/Psi codes + avg SNR - dequantization to radians Honesty boundary: VHT SU verified by round-trip + spec-table tests on SYNTHETIC frames (angle values CLAIMED until cross-decoded vs WiPiCap on a real capture); VHT MU codebook 1, HE, and EHT return Unsupported rather than guess. Scope is presence/motion/tracking + defensive privacy-exposure, not person re-identification (identity stays gated per ADR-120/141). Live capture is blocked on hardware: 5 MT7927 monitor captures yielded zero beamforming reports; needs AX210 or a BE550-controlled link (ADR-365 context). 6 cbr unit tests pass; crate suite green; clippy -D warnings (pedantic+nursery) clean. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
1 parent
5ef001b4fe
commit
24f2bd2e7b
4 files changed
+617
No files matched your search
@@ -0,0 +1,90 @@
|
||||
# ADR 365: BFI compressed-beamforming-report decoder
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed (VHT SU decoder implemented + unit-tested on SYNTHETIC frames; no live capture yet) |
|
||||
| **Date** | 2026-09-29 |
|
||||
| **Deciders** | ruv |
|
||||
| **Relates to** | ADR-118..123 (BFLD), ADR-141 (BFLD privacy control plane), ADR-323 (RTL8721Dx CSI), ADR-029 (multistatic) |
|
||||
|
||||
## Context
|
||||
|
||||
BFLD (ADR-118) is the plan to sense from 802.11 beamforming feedback (BFI) —
|
||||
the compressed steering matrix a client returns to an AP after sounding. The
|
||||
existing `wifi-densepose-bfld` crate implements the downstream envelope,
|
||||
privacy gating, and sensing/identity surfaces, but it consumes the beamforming
|
||||
angles only as an opaque `compressed_angle_matrix` blob. **Nothing in the tree
|
||||
decodes a real 802.11 Compressed Beamforming Report** into structured angles.
|
||||
That decoder is the missing front-end for every BFI use.
|
||||
|
||||
Two independent constraints shape this ADR:
|
||||
|
||||
1. **Capture is unproven on the hardware on hand.** Five monitor-mode captures
|
||||
on ruvultra's MediaTek MT7927 (idle, loaded, co-bound, and dedicated monitor
|
||||
on 5 GHz ch153/80 MHz with an active client speed test) yielded **zero**
|
||||
valid VHT/HE beamforming reports — only beacons and vendor action frames.
|
||||
The card's monitor path did not surface high-MCS unicast feedback at the
|
||||
sniffer's position (−75 dBm to the AP). Live capture therefore needs an
|
||||
Intel AX210 (the card KIT used) and/or a controlled BE550 network with the
|
||||
sniffer near the beamformed client. This is a hardware gate, not a code gate.
|
||||
|
||||
2. **Purpose boundary.** BFI is the signal KIT used to re-identify 197 people.
|
||||
This work targets device-free **presence/motion/tracking** and the
|
||||
**defensive privacy-exposure** analysis (ADR-121), on the operator's own
|
||||
network. Person re-identification of non-consenting people is out of scope;
|
||||
identity remains gated behind enrollment + consent per ADR-120/141.
|
||||
|
||||
## Decision
|
||||
|
||||
Add a pure-Rust `cbr` module to `wifi-densepose-bfld` that decodes a VHT
|
||||
Compressed Beamforming Action frame body into structured feedback:
|
||||
|
||||
- `VhtMimoControl` — Nc, Nr, channel width, grouping Ng, codebook, SU/MU,
|
||||
segmentation, sounding token (802.11-2020 §9.4.1.50).
|
||||
- `VhtBeamform` — per-stream average SNR, per-subcarrier Φ/ψ angle codes, the
|
||||
quantization widths, and dequantization to radians.
|
||||
- Angle count from the ordered Givens decomposition
|
||||
`Σ_{i=1}^{min(Nc,Nr-1)}(Nr−i)`, subcarrier counts from Table 9-91, codebook
|
||||
widths from Table 9-92.
|
||||
|
||||
Honesty boundary encoded in the code and its tests:
|
||||
|
||||
- **VHT SU** (codebook 0 and 1) is implemented and verified by round-trip and
|
||||
spec-table unit tests on SYNTHETIC frames. Decoded angle *values* stay
|
||||
`CLAIMED` until a captured frame decodes identically under an independent
|
||||
tool (WiPiCap / Wi-BFI).
|
||||
- **VHT MU codebook 1** returns `Unsupported` rather than embed unverified
|
||||
angle widths.
|
||||
- **HE and EHT** angle bitstreams return `Unsupported`.
|
||||
|
||||
The decoder is allocation-light, `#![forbid(unsafe_code)]` (crate-wide), and
|
||||
passes the crate's `-D warnings` clippy gate (pedantic + nursery).
|
||||
|
||||
## Consequences
|
||||
|
||||
- The BFLD pipeline can be fed real beamforming angles once a capture path
|
||||
exists; until then the decoder is exercised on synthetic frames.
|
||||
- MU codebook 1 / HE / EHT are explicit gaps, surfaced as typed errors so a
|
||||
capture of an unsupported format fails closed instead of producing garbage.
|
||||
- The next deliverable is the one the plan calls for: a real capture on an
|
||||
AX210 or BE550-controlled link, a report-rate dashboard, and cross-decoding
|
||||
a captured frame against WiPiCap to promote angle values from CLAIMED to
|
||||
MEASURED.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
cd v2 && cargo test -p wifi-densepose-bfld --lib cbr::
|
||||
cd v2 && cargo clippy -p wifi-densepose-bfld --features mqtt --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
6 cbr unit tests pass (angle-count table, MIMO Control round-trip, 2×2 SU
|
||||
end-to-end round-trip, MU-codebook-1 refusal, non-beamforming rejection,
|
||||
truncation). Full crate suite green.
|
||||
|
||||
## References
|
||||
|
||||
- IEEE 802.11-2020 §9.4.1.50 (VHT MIMO Control), §9.4.1.51 (VHT Compressed
|
||||
Beamforming Report), Tables 9-91/9-92.
|
||||
- WiPiCap (github.com/sarulab-ou/WiPiCap) — ac/ax reference decoder, MIT.
|
||||
- Wi-BFI (github.com/kfoysalhaque/Wi-BFI) — independent comparison (omits 2×2).
|
||||
@@ -116,6 +116,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-346](ADR-346-fail-closed-edge-occupancy-evidence.md) | Fail closed ESP32 occupancy evidence | Accepted (C6 occupancy integrity qualified) |
|
||||
| [ADR-347](ADR-347-rate-aware-esp32-temporal-sensing.md) | Rate aware ESP32 temporal sensing | Accepted (C6 timing and transport qualified) |
|
||||
| [ADR-363](ADR-363-native-macos-usb-node-onboarding.md) | Native macOS USB node onboarding with nonce bound discovery and fresh server verification | Accepted and physically qualified on one ESP32 S3; C6 and signed distribution pending |
|
||||
| [ADR-365](ADR-365-bfi-compressed-beamforming-report-decoder.md) | BFI compressed-beamforming-report decoder (VHT SU) for the BFLD front-end | Proposed (decoder unit-tested on synthetic frames; live capture gated on AX210/BE550) |
|
||||
| [ADR-115](ADR-115-home-assistant-integration.md) | Home Assistant integration via MQTT auto-discovery + Matter bridge (HA-DISCO + HA-FABRIC + HA-MIND) | Accepted (MQTT track) / Proposed (Matter SDK P8b) |
|
||||
| [ADR-169](ADR-169-adam-mode-light-theme.md) | adam-mode — light theme toggle for the three.js realtime demo | Proposed |
|
||||
| [ADR-170](ADR-170-yoga-mode-pose-system.md) | yoga-mode — yoga pose detection, classification, and scoring for the three.js realtime demo | Proposed |
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
//! Compressed Beamforming Report (CBR) decoder for raw 802.11 beamforming
|
||||
//! feedback.
|
||||
//!
|
||||
//! The BFLD pipeline (ADR-118/119) currently consumes the beamforming feedback
|
||||
//! only as an opaque `compressed_angle_matrix` blob. This module turns a real
|
||||
//! VHT (802.11ac) Compressed Beamforming Action frame body into structured
|
||||
//! `Phi`/`Psi` Givens angles, per-stream average SNR, and reported dimensions,
|
||||
//! so a sensing model can learn from the actual feedback rather than a
|
||||
//! pre-computed proxy.
|
||||
//!
|
||||
//! Scope and honesty boundary (the `MEASURED`/`CLAIMED`/`SYNTHETIC` rule):
|
||||
//! - **VHT SU** follows IEEE 802.11-2020 §9.4.1.51 and is exercised by
|
||||
//! round-trip and spec-table unit tests on `SYNTHETIC` frames. It has not yet
|
||||
//! been cross-checked against a real over-the-air capture or an independent
|
||||
//! decoder (`WiPiCap` / `Wi-BFI`), so decoded angle *values* are `CLAIMED`
|
||||
//! until a captured frame decodes identically under a second tool.
|
||||
//! - **VHT MU** codebook-1 angle widths are not encoded here because the author
|
||||
//! could not verify them; that case returns [`CbrError::Unsupported`].
|
||||
//! - **HE** and **EHT** angle bitstreams are [`CbrError::Unsupported`].
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
// `phi`/`psi` and `nr`/`nc` are the IEEE 802.11 names for these quantities;
|
||||
// renaming them for lint purposes would obscure the spec mapping.
|
||||
#![allow(clippy::similar_names)]
|
||||
|
||||
/// Errors from CBR parsing. All are recoverable — the caller drops the frame.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CbrError {
|
||||
/// Frame body too short for the field being read.
|
||||
Truncated {
|
||||
/// Bytes required to read the field.
|
||||
need: usize,
|
||||
/// Bytes actually present.
|
||||
have: usize,
|
||||
},
|
||||
/// Category/Action octets are not a supported beamforming report.
|
||||
NotBeamforming {
|
||||
/// Action-frame category octet.
|
||||
category: u8,
|
||||
/// Action octet within the category.
|
||||
action: u8,
|
||||
},
|
||||
/// A dimension field is outside 802.11 limits (Nr/Nc in `1..=4`, `Nc<=Nr`).
|
||||
BadDimension {
|
||||
/// Number of rows (receive chains) reported.
|
||||
nr: u8,
|
||||
/// Number of columns (space-time streams) reported.
|
||||
nc: u8,
|
||||
},
|
||||
/// A recognized-but-not-implemented format (HE/EHT angles, MU codebook 1).
|
||||
Unsupported(&'static str),
|
||||
}
|
||||
|
||||
impl core::fmt::Display for CbrError {
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
match self {
|
||||
Self::Truncated { need, have } => {
|
||||
write!(f, "truncated CBR: need {need} bytes, have {have}")
|
||||
}
|
||||
Self::NotBeamforming { category, action } => {
|
||||
write!(f, "not a beamforming report: category={category} action={action}")
|
||||
}
|
||||
Self::BadDimension { nr, nc } => write!(f, "bad CBR dimension nr={nr} nc={nc}"),
|
||||
Self::Unsupported(w) => write!(f, "unsupported CBR format: {w}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CbrError {}
|
||||
|
||||
/// Channel width reported in the VHT MIMO Control field.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChannelWidth {
|
||||
/// 20 MHz.
|
||||
W20,
|
||||
/// 40 MHz.
|
||||
W40,
|
||||
/// 80 MHz.
|
||||
W80,
|
||||
/// 160 MHz (or 80+80).
|
||||
W160,
|
||||
}
|
||||
|
||||
impl ChannelWidth {
|
||||
fn from_bits(v: u8) -> Self {
|
||||
match v & 0x3 {
|
||||
0 => Self::W20,
|
||||
1 => Self::W40,
|
||||
2 => Self::W80,
|
||||
_ => Self::W160,
|
||||
}
|
||||
}
|
||||
|
||||
/// Nominal channel width in MHz, for logging and link metadata.
|
||||
#[must_use]
|
||||
pub fn mhz(self) -> u16 {
|
||||
match self {
|
||||
Self::W20 => 20,
|
||||
Self::W40 => 40,
|
||||
Self::W80 => 80,
|
||||
Self::W160 => 160,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Subcarrier grouping Ng (1, 2 or 4 subcarriers per feedback point).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Grouping {
|
||||
/// One feedback point per subcarrier.
|
||||
Ng1,
|
||||
/// One feedback point per two subcarriers.
|
||||
Ng2,
|
||||
/// One feedback point per four subcarriers.
|
||||
Ng4,
|
||||
}
|
||||
|
||||
impl Grouping {
|
||||
fn from_bits(v: u8) -> Result<Self, CbrError> {
|
||||
match v & 0x3 {
|
||||
0 => Ok(Self::Ng1),
|
||||
1 => Ok(Self::Ng2),
|
||||
2 => Ok(Self::Ng4),
|
||||
_ => Err(CbrError::Unsupported("reserved grouping Ng=3")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed VHT MIMO Control field (802.11-2020 §9.4.1.50): 24 bits / 3 octets,
|
||||
/// packed least-significant-bit first.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct VhtMimoControl {
|
||||
/// Number of columns Nc (space-time streams fed back), `1..=4`.
|
||||
pub nc: u8,
|
||||
/// Number of rows Nr (beamformee receive chains), `1..=4`.
|
||||
pub nr: u8,
|
||||
/// Reported channel width.
|
||||
pub width: ChannelWidth,
|
||||
/// Subcarrier grouping Ng.
|
||||
pub grouping: Grouping,
|
||||
/// Codebook-information bit (selects the angle quantization widths).
|
||||
pub codebook: bool,
|
||||
/// Feedback type: `false` = SU, `true` = MU.
|
||||
pub mu: bool,
|
||||
/// Remaining feedback segments (segmentation, 802.11 §9.4.1.50).
|
||||
pub remaining_segments: u8,
|
||||
/// First-feedback-segment flag.
|
||||
pub first_segment: bool,
|
||||
/// Sounding dialog token this report answers.
|
||||
pub sounding_token: u8,
|
||||
}
|
||||
|
||||
fn take_u8(v: u32, shift: u32, width: u32) -> u8 {
|
||||
let mask = (1u32 << width) - 1;
|
||||
u8::try_from((v >> shift) & mask).expect("field width <= 8 bits")
|
||||
}
|
||||
|
||||
impl VhtMimoControl {
|
||||
fn parse(b: &[u8]) -> Result<Self, CbrError> {
|
||||
if b.len() < 3 {
|
||||
return Err(CbrError::Truncated { need: 3, have: b.len() });
|
||||
}
|
||||
let v = u32::from(b[0]) | (u32::from(b[1]) << 8) | (u32::from(b[2]) << 16);
|
||||
let nc = take_u8(v, 0, 3) + 1;
|
||||
let nr = take_u8(v, 3, 3) + 1;
|
||||
if !(1..=4).contains(&nr) || !(1..=4).contains(&nc) || nc > nr {
|
||||
return Err(CbrError::BadDimension { nr, nc });
|
||||
}
|
||||
Ok(Self {
|
||||
nc,
|
||||
nr,
|
||||
width: ChannelWidth::from_bits(take_u8(v, 6, 2)),
|
||||
grouping: Grouping::from_bits(take_u8(v, 8, 2))?,
|
||||
codebook: take_u8(v, 10, 1) == 1,
|
||||
mu: take_u8(v, 11, 1) == 1,
|
||||
remaining_segments: take_u8(v, 12, 3),
|
||||
first_segment: take_u8(v, 15, 1) == 1,
|
||||
sounding_token: take_u8(v, 18, 6),
|
||||
})
|
||||
}
|
||||
|
||||
/// `(psi_bits, phi_bits)` per 802.11-2020 Table 9-92, verified rows only.
|
||||
fn angle_bits(self) -> Result<(u32, u32), CbrError> {
|
||||
Ok(match (self.mu, self.codebook) {
|
||||
(false, false) => (5, 7), // SU codebook 0
|
||||
// SU codebook 1 and MU codebook 0 share the (7, 9) widths.
|
||||
(false, true) | (true, false) => (7, 9),
|
||||
(true, true) => {
|
||||
return Err(CbrError::Unsupported("MU codebook 1 angle widths unverified"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of `Phi` angles (equal to the number of `Psi` angles) per subcarrier
|
||||
/// for an `(Nr, Nc)` feedback matrix.
|
||||
///
|
||||
/// From the ordered Givens decomposition (802.11-2020 §19.3.12.3.6):
|
||||
/// `sum_{i=1}^{min(Nc, Nr-1)} (Nr - i)`. Verified against the standard's angle
|
||||
/// table in unit tests.
|
||||
#[must_use]
|
||||
pub fn angles_per_subcarrier(nr: u8, nc: u8) -> usize {
|
||||
let last = core::cmp::min(nc, nr.saturating_sub(1));
|
||||
(1..=last).map(|i| usize::from(nr - i)).sum()
|
||||
}
|
||||
|
||||
/// Number of subcarriers Ns carrying angles, from bandwidth and grouping
|
||||
/// (802.11-2020 Table 9-91, VHT).
|
||||
fn num_subcarriers(width: ChannelWidth, ng: Grouping) -> usize {
|
||||
use ChannelWidth::{W160, W20, W40, W80};
|
||||
use Grouping::{Ng1, Ng2, Ng4};
|
||||
// Verbatim Table 9-91; distinct (width, grouping) rows are kept separate for
|
||||
// legibility even where two happen to share a subcarrier count.
|
||||
#[allow(clippy::match_same_arms)]
|
||||
match (width, ng) {
|
||||
(W20, Ng1) => 52,
|
||||
(W20, Ng2) => 30,
|
||||
(W20, Ng4) => 16,
|
||||
(W40, Ng1) => 108,
|
||||
(W40, Ng2) => 58,
|
||||
(W40, Ng4) => 30,
|
||||
(W80, Ng1) => 234,
|
||||
(W80, Ng2) => 122,
|
||||
(W80, Ng4) => 62,
|
||||
(W160, Ng1) => 468,
|
||||
(W160, Ng2) => 244,
|
||||
(W160, Ng4) => 124,
|
||||
}
|
||||
}
|
||||
|
||||
/// A fully decoded VHT compressed beamforming report.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VhtBeamform {
|
||||
/// Parsed MIMO Control field.
|
||||
pub control: VhtMimoControl,
|
||||
/// Average SNR per space-time stream, raw `i8` (0.25 dB units per spec).
|
||||
pub avg_snr: Vec<i8>,
|
||||
/// Number of subcarriers carrying angles.
|
||||
pub num_subcarriers: usize,
|
||||
/// `Phi` (= `Psi`) angle count per subcarrier.
|
||||
pub angles_per_sub: usize,
|
||||
/// Bit width of each `Psi` code.
|
||||
pub psi_bits: u32,
|
||||
/// Bit width of each `Phi` code.
|
||||
pub phi_bits: u32,
|
||||
/// `Phi` codes, flattened as `phi[sub * angles_per_sub + k]`.
|
||||
pub phi: Vec<u16>,
|
||||
/// `Psi` codes, same layout as [`Self::phi`].
|
||||
pub psi: Vec<u16>,
|
||||
}
|
||||
|
||||
impl VhtBeamform {
|
||||
/// Dequantize a `Phi` code to radians: `phi = (k + 1/2) * pi / 2^(bphi-1)`,
|
||||
/// spanning `(0, 2*pi)`.
|
||||
#[must_use]
|
||||
pub fn phi_radians(&self, code: u16) -> f64 {
|
||||
(f64::from(code) + 0.5) * core::f64::consts::PI / f64::from(1u32 << (self.phi_bits - 1))
|
||||
}
|
||||
|
||||
/// Dequantize a `Psi` code to radians: `psi = (k + 1/2) * pi / 2^(bpsi+1)`,
|
||||
/// spanning `(0, pi/2)`.
|
||||
#[must_use]
|
||||
pub fn psi_radians(&self, code: u16) -> f64 {
|
||||
(f64::from(code) + 0.5) * core::f64::consts::PI / f64::from(1u32 << (self.psi_bits + 1))
|
||||
}
|
||||
}
|
||||
|
||||
/// Least-significant-bit-first bit reader (802.11 on-air order: B0 = LSB of
|
||||
/// octet 0). Reads at most 16 bits per call.
|
||||
struct BitReader<'a> {
|
||||
bytes: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> BitReader<'a> {
|
||||
fn new(bytes: &'a [u8]) -> Self {
|
||||
Self { bytes, pos: 0 }
|
||||
}
|
||||
|
||||
fn read(&mut self, count: u32) -> Result<u16, CbrError> {
|
||||
let mut out = 0u16;
|
||||
for i in 0..count {
|
||||
let byte_idx = self.pos / 8;
|
||||
if byte_idx >= self.bytes.len() {
|
||||
return Err(CbrError::Truncated { need: byte_idx + 1, have: self.bytes.len() });
|
||||
}
|
||||
let one_bit = (self.bytes[byte_idx] >> (self.pos % 8)) & 1;
|
||||
out |= u16::from(one_bit) << i;
|
||||
self.pos += 1;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a VHT Compressed Beamforming Action frame body.
|
||||
///
|
||||
/// `body` starts at the Category octet:
|
||||
/// `[Category=21][VHTAction=0][MIMO ctl x3][avg SNR xNc][angle bitstream…]`.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`CbrError`] when `body` is truncated, is not a VHT compressed
|
||||
/// beamforming report, carries an out-of-range dimension, or uses a format that
|
||||
/// is recognized but not yet decoded (MU codebook 1).
|
||||
pub fn parse_vht_action(body: &[u8]) -> Result<VhtBeamform, CbrError> {
|
||||
if body.len() < 2 {
|
||||
return Err(CbrError::Truncated { need: 2, have: body.len() });
|
||||
}
|
||||
let (category, action) = (body[0], body[1]);
|
||||
// Category 21 = VHT, VHT Action 0 = Compressed Beamforming.
|
||||
if category != 21 || action != 0 {
|
||||
return Err(CbrError::NotBeamforming { category, action });
|
||||
}
|
||||
parse_vht_report(&body[2..])
|
||||
}
|
||||
|
||||
/// Parse the VHT report starting at the MIMO Control field (no Category/Action).
|
||||
///
|
||||
/// # Errors
|
||||
/// Same conditions as [`parse_vht_action`].
|
||||
pub fn parse_vht_report(rep: &[u8]) -> Result<VhtBeamform, CbrError> {
|
||||
let control = VhtMimoControl::parse(rep)?;
|
||||
if control.mu && control.codebook {
|
||||
return Err(CbrError::Unsupported("MU codebook 1 angle widths unverified"));
|
||||
}
|
||||
let (psi_bits, phi_bits) = control.angle_bits()?;
|
||||
let nc = usize::from(control.nc);
|
||||
|
||||
let snr_start = 3;
|
||||
let snr_end = snr_start + nc;
|
||||
if rep.len() < snr_end {
|
||||
return Err(CbrError::Truncated { need: snr_end, have: rep.len() });
|
||||
}
|
||||
let avg_snr: Vec<i8> = rep[snr_start..snr_end].iter().map(|&b| b.cast_signed()).collect();
|
||||
|
||||
let ns = num_subcarriers(control.width, control.grouping);
|
||||
let per = angles_per_subcarrier(control.nr, control.nc);
|
||||
let mut reader = BitReader::new(&rep[snr_end..]);
|
||||
|
||||
let mut phi = Vec::with_capacity(ns * per);
|
||||
let mut psi = Vec::with_capacity(ns * per);
|
||||
// Per subcarrier the bitstream is grouped by Givens level i=1..min(Nc,Nr-1):
|
||||
// (Nr-i) Phi codes then (Nr-i) Psi codes. Flattened here into phi[]/psi[]
|
||||
// in that order; (nr,nc) recovers the level structure.
|
||||
let last = core::cmp::min(control.nc, control.nr.saturating_sub(1));
|
||||
for _ in 0..ns {
|
||||
for i in 1..=last {
|
||||
let level_count = control.nr - i;
|
||||
for _ in 0..level_count {
|
||||
phi.push(reader.read(phi_bits)?);
|
||||
}
|
||||
for _ in 0..level_count {
|
||||
psi.push(reader.read(psi_bits)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug_assert_eq!(phi.len(), ns * per);
|
||||
debug_assert_eq!(psi.len(), ns * per);
|
||||
|
||||
Ok(VhtBeamform {
|
||||
control,
|
||||
avg_snr,
|
||||
num_subcarriers: ns,
|
||||
angles_per_sub: per,
|
||||
psi_bits,
|
||||
phi_bits,
|
||||
phi,
|
||||
psi,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
angles_per_subcarrier, parse_vht_action, CbrError, ChannelWidth, Grouping, VhtMimoControl,
|
||||
};
|
||||
|
||||
/// Build a VHT MIMO Control (3 octets, LSB-first) from fields.
|
||||
fn mk_ctl(nc: u8, nr: u8, width: u8, ng: u8, codebook: u8, mu: u8, token: u8) -> [u8; 3] {
|
||||
let v: u32 = u32::from(nc - 1)
|
||||
| (u32::from(nr - 1) << 3)
|
||||
| (u32::from(width) << 6)
|
||||
| (u32::from(ng) << 8)
|
||||
| (u32::from(codebook) << 10)
|
||||
| (u32::from(mu) << 11)
|
||||
| (u32::from(token) << 18);
|
||||
v.to_le_bytes()[..3].try_into().expect("3 bytes")
|
||||
}
|
||||
|
||||
/// LSB-first bit packer mirroring the decoder, for building test frames.
|
||||
#[derive(Default)]
|
||||
struct BitPacker {
|
||||
out: Vec<u8>,
|
||||
acc: u32,
|
||||
held: u32,
|
||||
}
|
||||
impl BitPacker {
|
||||
fn push(&mut self, value: u16, width: u32) {
|
||||
let mask = (1u32 << width) - 1;
|
||||
self.acc |= (u32::from(value) & mask) << self.held;
|
||||
self.held += width;
|
||||
while self.held >= 8 {
|
||||
self.out.push(u8::try_from(self.acc & 0xff).expect("masked"));
|
||||
self.acc >>= 8;
|
||||
self.held -= 8;
|
||||
}
|
||||
}
|
||||
fn finish(mut self) -> Vec<u8> {
|
||||
if self.held > 0 {
|
||||
self.out.push(u8::try_from(self.acc & 0xff).expect("masked"));
|
||||
}
|
||||
self.out
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angle_count_table_matches_spec() {
|
||||
// 802.11-2020 Table 9-92 Na/2 (Phi count) for each (Nr, Nc).
|
||||
let cases = [
|
||||
((2, 1), 1),
|
||||
((2, 2), 1),
|
||||
((3, 1), 2),
|
||||
((3, 2), 3),
|
||||
((3, 3), 3),
|
||||
((4, 1), 3),
|
||||
((4, 2), 5),
|
||||
((4, 3), 6),
|
||||
((4, 4), 6),
|
||||
];
|
||||
for ((nr, nc), expect) in cases {
|
||||
assert_eq!(angles_per_subcarrier(nr, nc), expect, "Nr{nr}xNc{nc}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mimo_control_roundtrip() {
|
||||
let raw = mk_ctl(2, 2, 2, 0, 0, 0, 42); // 2x2, 80MHz, Ng1, SU cb0
|
||||
let c = VhtMimoControl::parse(&raw).unwrap();
|
||||
assert_eq!(c.nc, 2);
|
||||
assert_eq!(c.nr, 2);
|
||||
assert_eq!(c.width, ChannelWidth::W80);
|
||||
assert_eq!(c.grouping, Grouping::Ng1);
|
||||
assert!(!c.codebook && !c.mu);
|
||||
assert_eq!(c.sounding_token, 42);
|
||||
assert_eq!(c.angle_bits().unwrap(), (5, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_beamforming() {
|
||||
assert!(matches!(
|
||||
parse_vht_action(&[0x00, 0x00, 0, 0, 0]),
|
||||
Err(CbrError::NotBeamforming { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mu_codebook1_unsupported_not_guessed() {
|
||||
let raw = mk_ctl(2, 2, 2, 0, 1, 1, 0); // MU + codebook1
|
||||
let mut body = vec![21u8, 0];
|
||||
body.extend_from_slice(&raw);
|
||||
body.extend_from_slice(&[0; 64]);
|
||||
assert_eq!(
|
||||
parse_vht_action(&body),
|
||||
Err(CbrError::Unsupported("MU codebook 1 angle widths unverified"))
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end: synthesize a 2x2/80MHz/Ng1/SU-cb0 report with known angle
|
||||
/// codes, decode it, and confirm every code and the SNR round-trips.
|
||||
#[test]
|
||||
fn vht_2x2_roundtrip() {
|
||||
let (nr, nc) = (2u8, 2u8);
|
||||
let ctl = mk_ctl(nc, nr, 2, 0, 0, 0, 7); // 80MHz Ng1
|
||||
let ns = 234usize;
|
||||
let per = angles_per_subcarrier(nr, nc); // 1
|
||||
let (psi_bits, phi_bits) = (5u32, 7u32);
|
||||
|
||||
let phi_codes: Vec<u16> =
|
||||
(0..ns * per).map(|i| u16::try_from(i * 3 % (1 << phi_bits)).unwrap()).collect();
|
||||
let psi_codes: Vec<u16> =
|
||||
(0..ns * per).map(|i| u16::try_from(i * 5 % (1 << psi_bits)).unwrap()).collect();
|
||||
|
||||
let mut packer = BitPacker::default();
|
||||
for s in 0..ns {
|
||||
for k in 0..per {
|
||||
packer.push(phi_codes[s * per + k], phi_bits);
|
||||
}
|
||||
for k in 0..per {
|
||||
packer.push(psi_codes[s * per + k], psi_bits);
|
||||
}
|
||||
}
|
||||
|
||||
let mut body = vec![21u8, 0];
|
||||
body.extend_from_slice(&ctl);
|
||||
body.extend_from_slice(&[10i8.cast_unsigned(), (-4i8).cast_unsigned()]); // avg SNR
|
||||
body.extend_from_slice(&packer.finish());
|
||||
|
||||
let r = parse_vht_action(&body).unwrap();
|
||||
assert_eq!(r.num_subcarriers, ns);
|
||||
assert_eq!(r.angles_per_sub, per);
|
||||
assert_eq!(r.avg_snr, vec![10, -4]);
|
||||
assert_eq!(r.phi, phi_codes);
|
||||
assert_eq!(r.psi, psi_codes);
|
||||
|
||||
let two_pi = 2.0 * core::f64::consts::PI;
|
||||
for &c in &r.phi {
|
||||
let a = r.phi_radians(c);
|
||||
assert!(a > 0.0 && a < two_pi, "phi {a} out of (0,2pi)");
|
||||
}
|
||||
for &c in &r.psi {
|
||||
let a = r.psi_radians(c);
|
||||
assert!(a > 0.0 && a < core::f64::consts::FRAC_PI_2, "psi {a} out of (0,pi/2)");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_stream_errs() {
|
||||
let ctl = mk_ctl(2, 2, 0, 0, 0, 0, 0); // 20MHz Ng1 -> 52 subs
|
||||
let mut body = vec![21u8, 0];
|
||||
body.extend_from_slice(&ctl);
|
||||
body.extend_from_slice(&[0, 0]); // SNR
|
||||
body.extend_from_slice(&[0u8; 4]); // far too few angle bytes
|
||||
assert!(matches!(parse_vht_action(&body), Err(CbrError::Truncated { .. })));
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ pub mod emitter;
|
||||
pub mod availability;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod event;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod cbr;
|
||||
pub mod frame;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod ha_discovery;
|
||||
|
||||
Reference in new issue
Block a user