tests: VHT encoding combos + sniff_air.py radiotap verifier#40
Merged
Conversation
Extends `tests/regress.py --encoding-matrix` (PR #39) with two follow-ups that together close the loop on chip-specific RX encoding limitations. ## What this is 1. **VHT combos in `ENCODING_COMBOS`.** Adds `VHT-BCC` and `VHT-LDPC` (radiotap bit 21, 22-byte VHT info field — single-user MCS 0 / NSS 1 / 20 MHz) alongside the existing HT combos (radiotap bit 19). The motivating case is RTL8821AU: its reported LDPC-RX-no limitation (per Eachine Sphere Link via @RomanLut) is on the VHT path, not HT. The chip's HT and VHT decoders are separate code paths in silicon, so an HT-LDPC test passing doesn't tell us anything about VHT-LDPC. Matrix grows from 16 → 24 cells per run (6 combos × 4 driver modes). 2. **`tests/sniff_air.py`.** Standalone helper that captures on a monitor-mode iface (intended use: AR9271, per kaeru ref `AR9271 as peer-sniffer for devourer TX validation`) and decodes each captured frame's radiotap to report what encoding actually flew. Filters on the canonical injection SA. Answers the "did mac80211 actually emit what inject_beacon.py requested, or strip the flags before the air" question that the encoding-matrix itself can't. Internal radiotap parser handles MCS info (bit 19) and VHT info (bit 21) — same fields the inject/txdemo sides emit. Round-trip tested: every combo emitted by inject_beacon.py parses back to the same (kind, mcs, nss, ldpc, stbc, bw) tuple in sniff_air.py. ## Implementation - `tests/inject_beacon.py`: `_build_radiotap_vht` helper (hand-built 22-byte VHT radiotap), `--vht / --vht-mcs / --vht-nss / --bandwidth 20|40|80|160` flags. Existing `--ldpc / --stbc / --mcs` work for both HT and VHT modes — they map to the appropriate field for whichever mode is active. - `txdemo/main.cpp`: when `DEVOURER_TX_VHT=1`, replaces the 13-byte HT radiotap prefix with a 22-byte VHT radiotap header built dynamically from `DEVOURER_TX_VHT_MCS / _VHT_NSS / _LDPC / _STBC / _BW` env vars. Refactored TX buffer to `std::vector<uint8_t>` so it can hold either prefix length. Cross-checked against `inject_beacon.py`'s builder for byte-identical output. - `tests/regress.py`: `ENCODING_COMBOS` extended with VHT-BCC / VHT-LDPC. `_devourer_env` + `_spawn_kernel_tx` pass `vht / vht_mcs / nss` through. No new flags. - `tests/sniff_air.py`: new file (~270 lines). Standalone, runs via `sudo python3 tests/sniff_air.py --iface <mon> --channel N --duration N`. Sets iface to monitor mode, captures via tcpdump, parses pcap manually (no scapy.rdpcap dependency — scapy's radiotap parser is hit-or-miss for LDPC/STBC bits), groups frames by encoding, prints distribution. - `tests/README.md`: documents both pieces. Replaces the "HT-only, validated didn't reproduce LDPC asymmetry" caveat with a more general "kernel-TX encoding flags may not always reach the air, use sniff_air.py to prove what flew" paragraph. ## Validation Built and ran end-to-end on the lab rig 2026-05-25, channel 100, RTL8814AU TX → RTL8821AU RX, VM mode (devourer-testrig). `--encoding-matrix` table (24 cells, all ran cleanly): | Mode | HT-BCC | HT-LDPC | HT-STBC=1 | HT-LDPC+STBC | VHT-BCC | VHT-LDPC | |---|---|---|---|---|---|---| | k/k | 466 ✓ | 455 ✓ | 462 ✓ | 443 ✓ | 435 ✓ | 453 ✓ | | d/k | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | | k/d | 400 ✓ | 400 ✓ | 400 ✓ | 400 ✓ | 400 ✓ | 400 ✓ | | d/d | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | VHT-LDPC `k/d` still flat at ~400 (same as VHT-BCC and HT-LDPC). 8821AU RX accepted every cell. Two reads: 1. Kernel-TX path strips radiotap LDPC bit regardless of HT vs VHT mode → would explain why the chip never has to refuse an LDPC frame. `tests/sniff_air.py` on an AR9271 would prove or disprove this. 2. 8821AU does not actually have the LDPC-RX-no limitation, or it only triggers under different conditions (specific MCS / NSS / bandwidth / aggregation pattern). Without an AR9271 in the rig today, can't pick between (1) and (2) in this commit. The infrastructure to answer it is now in place. `d/k` and `d/d` rows blank because 8814 TX is broken on master (known issue, separate from this PR). They are the ground-truth path for devourer-side encoding — `WiFiDriverTxDemo` writes radiotap directly into the chip bulk-OUT buffer, no kernel filtering. Sniffer parser unit-tested against every combo `inject_beacon.py` can emit (HT-BCC / HT-LDPC / HT-STBC=1 / VHT-BCC / VHT-LDPC / VHT-LDPC+STBC) — round-trips with byte-identical results. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 tasks
josephnef
added a commit
that referenced
this pull request
May 25, 2026
…#41) Closes the last of #39's three follow-ups: wires the radiotap verifier from #40 (`tests/sniff_air.py`) into `tests/regress.py` so it runs alongside every cell. Optional and opt-in — `--sniffer-iface` defaults to off, all prior modes unchanged. ## What this is ```bash sudo python3 tests/regress.py --encoding-matrix \ --tx-pid 0x8813 --rx-pid 0x0120 --channel 100 \ --vm-name devourer-testrig --vm-ssh <user>@<VM-IP> \ --sniffer-iface wlan0mon ``` Per-cell output gains a `↪ sniffer: N frames — <encoding>=N, ...` line under the existing hit-count line. Reports what actually flew on-air for each cell — closes the open question from #40 ("did the kernel-TX path actually emit LDPC, or strip the flag?"). Intended for an AR9271: vanilla radiotap, no driver-side filtering on what the cell injects. The chipset is widely used as a sniffer for exactly this reason. ## Implementation | Component | Behaviour | |---|---| | `_spawn_sniffer(iface, channel, pcap_path)` | Sets iface to monitor mode, runs `tcpdump -w pcap -U -nn 'ether src CANONICAL_SA'`. Always host-local; sniffer never moved into the VM via USB passthrough. | | `_summarise_sniffer_pcap(pcap_path)` | Imports sniff_air at runtime (sits next to regress.py), reuses `_read_pcap_frames` + `_parse_radiotap` + `_frame_sa` + `CANONICAL_SA` to bucket captured frames. Returns a one-line summary for the cell's `notes` field. | | `run_cell(sniffer_iface=...)` | Spawns sniffer between RX and TX stages so the full TX window gets captured. Sniffer failures are observational — never fail the cell on sniffer issues. | | `run_matrix` / `run_full_matrix` / `run_encoding_matrix` | Pass `sniffer_iface` through to `run_cell`. When active + `r.notes` is set, print an extra `↪ <notes>` line per cell. | | `--sniffer-iface IFACE` | New CLI flag + `DEVOURER_SNIFFER_IFACE` env equivalent. | ## Validation The dormant path (`sniffer_iface=None`) preserves the exact prior behaviour of every matrix mode — only structural change in `run_cell` is initializing `sniffer_proc=None` and a no-op cleanup if it stays None. Active-path validation requires an AR9271 plugged in, which isn't in the rig today. CI matrix builds will confirm the code paths compile / import. Functional end-to-end pending hardware. The sniffer parser itself was unit-tested in #40: every combo `inject_beacon.build_beacon` emits round-trips back through `sniff_air._parse_radiotap` with byte-identical decoded fields. ## What this PR doesn't touch - The markdown table emit functions are unchanged — sniffer notes go to per-cell stdout, not into the table. Could be added as a separate column in a future PR if interesting. - No new combos; `ENCODING_COMBOS` unchanged from #40. - AR9271-specific bring-up (driver loading, monitor capability detection). The flag takes a generic iface name; the user is expected to have a working monitor iface before pointing the matrix at it. ## Test plan - [x] `--help` lists `--sniffer-iface` - [x] Code parses, imports successfully - [x] CI matrix builds — pending on this push - [ ] `--sniffer-iface IFACE` with an AR9271 plugged in: verify per-cell `↪ sniffer:` lines appear and decode reasonably - [ ] Confirm `--sniffer-iface` running alongside `--encoding-matrix` shows different encoding distributions for `--ldpc` vs default cells (the actual goal — proves whether mac80211 / 88XXau emits the LDPC bit on-air) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #39 closing two of the three follow-ups noted in that PR's test plan.
What this is
Two pieces:
VHT combos in
--encoding-matrix.ENCODING_COMBOSgrows from 4 to 6 entries: addsVHT-BCCandVHT-LDPC(radiotap bit 21, 22-byte VHT info field, MCS 0 / NSS 1 / 20 MHz). Motivating case: RTL8821AU's LDPC-RX-no limitation (per @RomanLut, Eachine Sphere Link) is on the VHT path. The chip's HT and VHT decoders are separate silicon blocks, so tests: --encoding-matrix iterates radiotap LDPC/STBC per cell #39's HT-LDPC test passing didn't say anything about VHT-LDPC.tests/sniff_air.py. Standalone radiotap-decode helper. Capture on a monitor-mode iface (intended use: AR9271 — vanilla radiotap, no driver-side flag filtering), filter on the canonical injection SA, decode each captured frame's MCS / VHT field. Answers the question "did mac80211 actually emit what `inject_beacon.py` requested, or strip the encoding flags before the air?" — which is what tests: --encoding-matrix iterates radiotap LDPC/STBC per cell #39's flat-LDPC-row result was waiting on.Implementation
Validation on the lab rig (8814 TX → 8821 RX, ch 100, VM mode)
`--encoding-matrix` table (24 cells, all ran):
VHT-LDPC `k/d` still flat at ~400 hits, same as VHT-BCC and HT-LDPC. 8821AU RX accepted every cell. Two reads, neither distinguishable in this PR:
`d/k` and `d/d` rows are 0 because 8814 TX is broken on master (separate known issue, not in scope here).
Sniffer parser unit-tested against every combo `inject_beacon.py` emits — round-trips with byte-identical decoded fields.
What this PR doesn't touch
Test plan
🤖 Generated with Claude Code