diff --git a/CMakeLists.txt b/CMakeLists.txt index e027def..162f440 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -59,6 +59,7 @@ add_library(WiFiDriver src/RateDefinitions.h src/RxPacket.h src/TxDescBits.h + src/BeamformingSounder.h src/TxMode.cpp src/TxMode.h src/IRtlDevice.h diff --git a/demo/main.cpp b/demo/main.cpp index c6d1dd3..ba78c7a 100644 --- a/demo/main.cpp +++ b/demo/main.cpp @@ -192,6 +192,63 @@ static void packetProcessor(const Packet &packet) { packet.RxAtrib.snr[0], packet.RxAtrib.snr[1]); fflush(stdout); } + /* BF self-sounding report detector. A VHT/HT Compressed Beamforming report + * is a management Action frame — subtype 0xD0 (Action) or 0xE0 (Action + * No-Ack; CSI reports are sent No-Ack) — whose body begins with a category + * + action of VHT cat 0x15 act 0x00, or HT cat 0x07 act 0x00. When an armed + * beamformee replies to our NDPA+NDP the report's SA is the beamformee MAC, + * the decode-level confirmation the unassociated responder produced CSI. + * + * DEVOURER_BF_DETECT_REPORT modes: + * 1 summary (Nc/Nr/BW/Ng, SA) per report + * 2 FC/category of EVERY frame (subtype survey) + * 3 mode 1 + capped CSI-payload hexdump (first frames) + * 4 mode 1 + full frame hex (for the decoder, + * tools/bf_report_decode.py) */ + if (const char *mode_s = std::getenv("DEVOURER_BF_DETECT_REPORT")) { + if (packet.Data.size() >= 27) { + const char mode = mode_s[0]; + const uint8_t *d = packet.Data.data(); + const uint8_t cat = d[24], act = d[25]; + const bool vht = (cat == 0x15 && act == 0x00); + const bool ht = (cat == 0x07 && act == 0x00); + if (mode == '2') { + static int any = 0; + if (++any <= 4000) + printf("fc=%02x%02x cat=%02x act=%02x crc=%u " + "len=%zu\n", d[0], d[1], cat, act, + packet.RxAtrib.crc_err ? 1u : 0u, packet.Data.size()); + } + const uint8_t sub = d[0] & 0xF0; + if ((sub == 0xD0 || sub == 0xE0) && (vht || ht)) { + static int rpt = 0; + ++rpt; + const uint8_t *mc = d + 26; /* VHT MIMO control field */ + unsigned nc = (mc[0] & 0x07) + 1, nr = ((mc[0] >> 3) & 0x07) + 1; + unsigned chw = (mc[0] >> 6) & 0x03, ng = mc[1] & 0x03; + printf("%s n=%d sa=%02x:%02x:%02x:%02x:%02x:%02x " + "Nc=%u Nr=%u BW=%u Ng=%u len=%zu\n", + vht ? "VHT" : "HT", rpt, d[10], d[11], d[12], d[13], d[14], + d[15], nc, nr, chw, ng, packet.Data.size()); + if (mode == '3' && rpt <= 6) { + size_t off = 26 + 3; /* hdr(24)+cat+act+mimoctrl(3) */ + size_t end = packet.Data.size() >= 4 ? packet.Data.size() - 4 : off; + printf(" csi[%zu]:", end - off); + for (size_t i = off; i < end && i < off + 40; ++i) + printf(" %02x", d[i]); + printf("\n"); + } + if (mode == '4' && rpt <= 200) { + printf(""); + for (size_t i = 0; i < packet.Data.size(); ++i) + printf("%02x", d[i]); + printf("\n"); + } + fflush(stdout); + } + } + } + /* TX-validation hook: detect frames whose SA matches the txdemo's hardcoded * injected beacon (57:42:75:05:d6:00). When running this RX demo against * one adapter while WiFiDriverTxDemo runs against another on the same diff --git a/docs/beamforming-self-sounding.md b/docs/beamforming-self-sounding.md new file mode 100644 index 0000000..85d9764 --- /dev/null +++ b/docs/beamforming-self-sounding.md @@ -0,0 +1,94 @@ +# Beamforming self-sounding — per-subcarrier CSI from two adapters + +802.11ac beamforming sounding leaks per-subcarrier channel state: a beamformee +estimates the channel matrix H(k) per subcarrier from a sounding NDP, compresses +the steering matrix V(k) into Givens angles, and **transmits it back over the +air** as a VHT Compressed Beamforming report. On Jaguar-1 silicon that pipeline +is hardware-terminated — a chip cannot read back its own estimate — but the +report is addressed to the *beamformer*, so a second adapter (or a monitor RX) +captures it. With devourer driving both ends of the link, we sound our own +channel on demand and recover per-subcarrier CSI the chip otherwise hides. + +## The exchange + +``` +beamformer (adapter A) beamformee (adapter B) + ── NDPA (control frame) ───────────────▶ matches its own addr as RA + ── NDP (HW-generated) ────────────────▶ estimates H(k) from VHT-LTFs, + SVD → V(k), compresses to angles + ◀── VHT Compressed Beamforming report ── (Action No-Ack, over the air) +``` + +Two hardware facts made this work, both validated on real silicon: + +- The NDP is **hardware-generated**: marking the injected NDPA in the TX + descriptor is not enough on its own — the MAC sounding engine must be armed + first, or no NDP follows. +- The beamformee responds **without any association**: arming the responder + registers with the beamformer's MAC (address match only, P_AID = 0) is + sufficient; no CAM/macid entry is needed. + +## Register recipe + +All register values are in `src/jaguar1/BeamformingSounder.h`, transcribed from +the vendor `hal_txbf_jaguar_enter()` +(`reference/rtl8812au/hal/phydm/txbf/haltxbfjaguar.c`). Entry 0, P_AID 0. + +- **Beamformer (`arm_sounder`)**: `REG_SND_PTCL_CTRL` enable, NDP standby + timeout, `REG_TXBF_CTRL` NDPA-transmit enables, `REG_BFMEE_SEL` entry select. +- **Beamformee (`arm_beamformee`)**: `REG_SND_PTCL_CTRL` enable, standby + timeout, `REG_BFMER0_INFO` = beamformer MAC (matched against the NDPA TA), + `REG_CSI_RPT_PARAM_BW20/40/80` + BB CSI-content = report matrix dimensions. +- **NDPA descriptor (`mark_ndpa_descriptor`)**: TX-desc NDPA bit, no HW + sequence, unicast, use-header NAV, no rate fallback. + +## Driving it (demo env vars) + +```sh +# beamformee: arm to reply to sounding from the beamformer MAC (no association) +DEVOURER_VID=0x2357 DEVOURER_PID=0x0120 DEVOURER_CHANNEL=100 \ + DEVOURER_BF_ARM_BFEE= WiFiDriverDemo + +# beamformer: inject an NDPA to the beamformee, arm the sounding engine +DEVOURER_PID=0x8812 DEVOURER_CHANNEL=100 DEVOURER_TX_RATE=VHT2SS_MCS0 \ + DEVOURER_TX_NDPA_RA= DEVOURER_TX_NDPA=1 \ + DEVOURER_BF_ARM_SOUNDER=1 WiFiDriverTxDemo + +# capture + decode the reports (any monitor RX; the beamformer is addressed but +# monitor mode is promiscuous). Mode 4 dumps full frames for the decoder. +DEVOURER_PID=0x8813 DEVOURER_CHANNEL=100 DEVOURER_BF_DETECT_REPORT=4 \ + WiFiDriverDemo | tools/bf_report_decode.py +``` + +Gotchas that cost time during bring-up: + +- CSI reports are **Action No-Ack** (FC subtype `0xE0`), not Action (`0xD0`). + A detector matching only `0xD0` sees zero frames. +- The report frames are VHT PPDUs; a weak sniffer link drops them on FCS. + +## The report format (measured, 2×1 case) + +For a 2-antenna beamformer sounding a 1-antenna beamformee, each report is a +99-byte frame carrying, after the 24-byte MAC header: + +| field | bytes | value seen | +|---|---|---| +| Category / VHT Action | 24–25 | `0x15` / `0x00` | +| VHT MIMO Control | 26–28 | Nc=1, Nr=2, BW=20 MHz, Ng=1, codebook=1, SU | +| Avg SNR (per column) | 29 | int8, dB = 22 + 0.25·v | +| Compressed angles | 30–94 | 65 B = 520 bits | +| FCS | 95–98 | | + +The angle payload is **52 subcarriers × 10 bits** = 1 φ (6 bits) + 1 ψ (4 bits) +per tone — a *compact* codebook, narrower than the textbook VHT SU sizes +(7/5 or 9/7). The φ = ψ+2 relationship matches the standard Givens structure. +`tools/bf_report_decode.py` reconstructs the per-subcarrier 2×1 steering +vector V(k) = [cos ψ_k, e^{jφ_k} sin ψ_k], i.e. the relative per-tone channel +|h_B(k)/h_A(k)| = tan ψ_k and arg = φ_k between the beamformer's two antennas. + +Decode validation: cross-frame variance of ψ(k) over 200 over-air reports is +~0.0014 rad² (~2°) — the reports are repeatable; the LSB-first bit order is +confirmed by an MSB-first control blowing the variance up ~70×. On a short LOS +bench link the channel is flat across 20 MHz (coherence bandwidth exceeds the +channel), so per-tone structure is quantisation-limited; a wider bandwidth or a +multipath geometry is needed to exercise real frequency selectivity. diff --git a/src/BeamformingSounder.h b/src/BeamformingSounder.h new file mode 100644 index 0000000..47215b9 --- /dev/null +++ b/src/BeamformingSounder.h @@ -0,0 +1,137 @@ +/* BeamformingSounder — Realtek 802.11ac explicit-sounding helpers for the + * two-adapter "self-sounding" CSI path, shared across chip generations. + * + * With one adapter as beamformer and another as beamformee, an NDPA + + * hardware-NDP exchange makes the beamformee emit a VHT Compressed Beamforming + * report over the air, which a monitor RX captures — giving per-subcarrier + * channel state the chip otherwise terminates in hardware. See + * docs/beamforming-self-sounding.md. + * + * WHY THIS IS GENERATION-NEUTRAL: the MAC sounding registers are byte-identical + * across the whole Realtek AC family — kSndPtclCtrl (0x718), + * kBfmer0Info (0x6E4), REG_CSI_RPT_PARAM_BW20 (0x6F4), kBfmeeSel + * (0x714), kTxbfCtrl (0x42C) are the same on 8192E/8703B/8723D/8812A/ + * 8814A/8822B/8822C/8822E (verified against every vendor include tree). The + * beamformer arm (arm_sounder) is therefore fully shared. Only the beamformee + * PHY details differ per generation, and those are captured in BfeeConfig: + * - the sounding-protocol control byte (0xCB Jaguar-1 / 0xDB Jaguar-2/3); + * - the BB CSI-content register 0x9B4 is CSI content on Jaguar-1 but the + * NARROWBAND CLOCK DIVIDER on Jaguar-3 (RadioManagementJaguar3.h) — it + * MUST NOT be written there, so csi_content_reg is 0 on Jaguar-2/3; + * - Jaguar-2/3 additionally gate the responder behind RX-filter bits + * (accept NDPA + action-no-ack) and an own-AID register (0x1680). + * + * Register values transcribed from the vendor beamformee-entry functions: + * Jaguar-1 hal_txbf_jaguar_enter() (haltxbfjaguar.c) + * Jaguar-2/3 hal_txbf_8822b_enter() (haltxbf8822b.c, shared 8822B/C/E) + * Entry 0, P_AID = 0 (unassociated sounding — no CAM/macid entry). + */ +#ifndef BEAMFORMING_SOUNDER_H +#define BEAMFORMING_SOUNDER_H + +#include + +#include "RtlUsbAdapter.h" + +namespace devourer { +namespace bf { + +/* Shared MAC sounding registers (identical across the Realtek AC family). + * k-prefixed to avoid colliding with the REG_* preprocessor macros in the + * vendor hal_com_reg.h (macros ignore namespaces). */ +enum : uint16_t { + kTxbfCtrl = 0x042C, + kRxFltMap0 = 0x06A0, /* +1 bit6 = accept action-no-ack */ + kRxFltMap1 = 0x06A2, /* [5:4] = accept NDPA + BF report poll */ + kBfmer0Info = 0x06E4, + kCsiRptParam20 = 0x06F4, + kCsiRptParam40 = 0x06F8, + kCsiRptParam80 = 0x06FC, + kBfmeeSel = 0x0714, + kSndPtclCtrl = 0x0718, + kSndNdpStandby = 0x071B, + kBbCsiContent = 0x09B4, /* Jaguar-1 only (clock divider on Jaguar-3) */ + kOurAid = 0x1680, /* Jaguar-2/3 only: our AID [11:0] */ +}; + +enum : uint8_t { + kTxbfNdpaEnables = 0x10 | 0x40 | 0x80, /* 0x42F BIT4|6|7: NDPA TX enable */ + kRxFlt0ActNoAck = 0x40, /* 0x6A1 bit6 */ + kRxFlt1NdpaPoll = 0x30, /* 0x6A2 [5:4] */ +}; + +/* Per-generation beamformee knobs. */ +struct BfeeConfig { + uint8_t snd_ptcl_ctrl; /* 0xCB Jaguar-1 / 0xDB Jaguar-2/3 */ + uint8_t ndp_standby; /* 0x50 */ + uint32_t csi_rpt_param; /* report matrix dims (1T1R VHT SU = 0x0109) */ + bool csi_rpt_param_16; /* 16-bit @0x6F4 (Jg2/3) vs 32-bit x3 (Jg1) */ + uint16_t csi_content_reg; /* 0x9B4 on Jaguar-1; 0 = skip on Jaguar-2/3 */ + uint32_t csi_content_val; + bool set_our_aid; /* write 0 to 0x1680 (Jaguar-2/3) */ + bool set_rxflt; /* enable NDPA/action-no-ack RX (Jaguar-2/3) */ +}; + +/* Jaguar-1 (8812/8814/8821): BB CSI-content at 0x9B4, 32-bit params, no + * RX-filter poke (monitor mode already accepts the frames). */ +constexpr BfeeConfig kBfeeJaguar1{ + /*snd_ptcl_ctrl*/ 0xCB, /*ndp_standby*/ 0x50, + /*csi_rpt_param*/ 0x01080108, /*csi_rpt_param_16*/ false, + /*csi_content_reg*/ kBbCsiContent, /*csi_content_val*/ 0x01081008, + /*set_our_aid*/ false, /*set_rxflt*/ false}; + +/* Jaguar-2/3 (8822B/C/E): 0xDB, 16-bit param at 0x6F4, own-AID + RX-filter + * gates, and NO 0x9B4 write (that address is the narrowband clock divider). */ +constexpr BfeeConfig kBfeeJaguar23{ + /*snd_ptcl_ctrl*/ 0xDB, /*ndp_standby*/ 0x50, + /*csi_rpt_param*/ 0x0109, /*csi_rpt_param_16*/ true, + /*csi_content_reg*/ 0, /*csi_content_val*/ 0, + /*set_our_aid*/ true, /*set_rxflt*/ true}; + +/* Beamformer side (fully shared): arm the MAC sounding engine so a + * TX-descriptor-marked NDPA is followed by a hardware-generated NDP. + * Beamformee entry 0, P_AID = 0 (unassociated). */ +inline void arm_sounder(RtlUsbAdapter& dev) { + dev.rtw_write8(kSndPtclCtrl, 0xCB); + dev.rtw_write8(kSndNdpStandby, 0x50); + dev.rtw_write16(kTxbfCtrl, 0x0000); /* P_AID = 0 */ + uint8_t v = dev.rtw_read8(kTxbfCtrl + 3); + dev.rtw_write8(kTxbfCtrl + 3, v | kTxbfNdpaEnables); + uint8_t s = dev.rtw_read8(kBfmeeSel + 3); + dev.rtw_write8(kBfmeeSel + 3, (s & 0x03) | 0x60); + dev.rtw_write16(kBfmeeSel, 0x0200); /* entry-0 select */ +} + +/* Beamformee side: arm the hardware CSI responder to reply to NDPA+NDP from + * `beamformer_mac` (matched against the NDPA TA), no association required. + * `cfg` selects the per-generation register recipe. */ +inline void arm_beamformee(RtlUsbAdapter& dev, const uint8_t beamformer_mac[6], + const BfeeConfig& cfg) { + dev.rtw_write8(kSndPtclCtrl, cfg.snd_ptcl_ctrl); + dev.rtw_write8(kSndNdpStandby, cfg.ndp_standby); + for (uint16_t i = 0; i < 6; ++i) + dev.rtw_write8(kBfmer0Info + i, beamformer_mac[i]); + if (cfg.set_our_aid) + dev.rtw_write16(kOurAid, 0x0000); /* our AID = 0 */ + if (cfg.set_rxflt) { + uint8_t r0 = dev.rtw_read8(kRxFltMap0 + 1); + dev.rtw_write8(kRxFltMap0 + 1, r0 | kRxFlt0ActNoAck); + uint8_t r1 = dev.rtw_read8(kRxFltMap1); + dev.rtw_write8(kRxFltMap1, r1 | kRxFlt1NdpaPoll); + } + if (cfg.csi_rpt_param_16) { + dev.rtw_write16(kCsiRptParam20, + static_cast(cfg.csi_rpt_param)); + } else { + dev.rtw_write32(kCsiRptParam20, cfg.csi_rpt_param); + dev.rtw_write32(kCsiRptParam40, cfg.csi_rpt_param); + dev.rtw_write32(kCsiRptParam80, cfg.csi_rpt_param); + } + if (cfg.csi_content_reg) + dev.rtw_write32(cfg.csi_content_reg, cfg.csi_content_val); +} + +} // namespace bf +} // namespace devourer + +#endif // BEAMFORMING_SOUNDER_H diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index 40cf8f7..c52a017 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -1,4 +1,5 @@ #include "RtlJaguarDevice.h" +#include "BeamformingSounder.h" #include "EepromManager.h" #include "RadioManagementModule.h" #include "SignalStop.h" @@ -30,6 +31,15 @@ void RtlJaguarDevice::InitWrite(SelectedChannel channel) { StartWithMonitorMode(channel); SetMonitorChannel(channel); _logger->info("In Monitor Mode"); + + /* DEVOURER_BF_ARM_SOUNDER=1 — beamforming self-sounding probe (beamformer + * side): arm the MAC's hardware sounding engine so a TX-descriptor-marked + * NDPA (DEVOURER_TX_NDPA=1) is followed by a hardware-generated NDP. See + * BeamformingSounder.h for the vendor register recipe. */ + if (std::getenv("DEVOURER_BF_ARM_SOUNDER")) { + devourer::bf::arm_sounder(_device); + _logger->info("BF sounder armed (beamformer side)"); + } } /* Map a radiotap CHANNEL frequency (MHz) to a Wi-Fi channel number. Returns 0 @@ -344,6 +354,22 @@ bool RtlJaguarDevice::send_packet(const uint8_t *packet, size_t length) { SET_TX_DESC_DATA_STBC_8812(usb_frame, stbc & 3); + /* DEVOURER_TX_NDPA=1 — beamforming self-sounding probe: mark the injected + * frame as an NDPA (TX-desc Dword3 [23:22]=1) so the MAC sounding engine + * follows it with a hardware-generated NDP (pairs with + * DEVOURER_BF_ARM_SOUNDER + the txdemo NDPA builder DEVOURER_TX_NDPA_RA). + * The descriptor layout is Jaguar-1-specific, so this stays in the HAL + * (the shared BeamformingSounder.h only holds the generation-neutral MAC + * register recipe). NDPA is a unicast control frame: no HW sequence stamp, + * not broadcast, use-header NAV, no rate fallback. */ + if (std::getenv("DEVOURER_TX_NDPA")) { + SET_TX_DESC_NDPA_8812(usb_frame, 1); + SET_TX_DESC_HWSEQ_EN_8812(usb_frame, 0); + SET_TX_DESC_BMC_8812(usb_frame, 0); + SET_TX_DESC_NAV_USE_HDR_8812(usb_frame, 1); + SET_TX_DESC_DISABLE_FB_8812(usb_frame, 1); + } + rtl8812a_cal_txdesc_chksum(usb_frame); _logger->debug("tx desc formed"); #ifdef DEBUG @@ -405,6 +431,24 @@ void RtlJaguarDevice::Init(Action_ParsedRadioPacket packetProcessor, StartWithMonitorMode(channel); SetMonitorChannel(channel); + /* DEVOURER_BF_ARM_BFEE=aa:bb:cc:dd:ee:ff — beamforming self-sounding probe + * (beamformee side): arm the hardware CSI responder so an NDPA+NDP from the + * given beamformer MAC triggers a hardware-built VHT Compressed Beamforming + * report, with NO association. The chip's own MAC (0x610, from EFUSE) is the + * NDPA RA match. See BeamformingSounder.h for the register recipe. */ + if (const char *bfer = std::getenv("DEVOURER_BF_ARM_BFEE")) { + unsigned m[6]; + if (std::sscanf(bfer, "%x:%x:%x:%x:%x:%x", &m[0], &m[1], &m[2], &m[3], + &m[4], &m[5]) == 6) { + uint8_t mac[6]; + for (int i = 0; i < 6; ++i) mac[i] = static_cast(m[i]); + devourer::bf::arm_beamformee(_device, mac, devourer::bf::kBfeeJaguar1); + _logger->info("BF beamformee armed for beamformer {}", bfer); + } else { + _logger->error("DEVOURER_BF_ARM_BFEE — bad MAC '{}'", bfer); + } + } + /* DEVOURER_RX_PATHS=0xNN restricts which RX chains the chip enables/combines, * by masking the RX-path-enable register (0x808 byte 0: bits 0/4 = path A * CCK/OFDM, 1/5 = B, 2/6 = C, 3/7 = D). Default is all paths (0xFF, from the diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index 3d63e3a..6289572 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -1,9 +1,13 @@ #include "RtlJaguar3Device.h" +#include +#include #include #include #include +#include "BeamformingSounder.h" /* generation-neutral BF self-sounding recipe */ + #include "FrameParserJaguar3.h" #include "RateDefinitions.h" /* MGN_* rate enum (shared across the family) */ #include "SignalStop.h" /* g_devourer_should_stop — set by demo signal handlers */ @@ -46,6 +50,33 @@ void RtlJaguar3Device::Init(Action_ParsedRadioPacket packetProcessor, _hal.config_channel_8822e(channel.Channel); /* 8822e band TX scaling/backoff + shaping */ _hal.coex_wlan_only_init(); /* lock antenna to WLAN (disable BT/LTE coex) */ + /* DEVOURER_BF_ARM_BFEE=aa:bb:cc:dd:ee:ff — beamforming self-sounding probe + * (beamformee side), Jaguar-3 variant. Arms the hardware CSI responder to + * reply to NDPA+NDP from the given beamformer MAC with a VHT Compressed + * Beamforming report, no association. Uses the shared MAC recipe with the + * Jaguar-2/3 config (0xDB, 16-bit CSI param, RX-filter + own-AID gates, and + * crucially NO 0x9B4 write — that address is the narrowband clock divider on + * this generation). See BeamformingSounder.h. */ + if (const char *bfer = std::getenv("DEVOURER_BF_ARM_BFEE")) { + unsigned m[6]; + if (std::sscanf(bfer, "%x:%x:%x:%x:%x:%x", &m[0], &m[1], &m[2], &m[3], + &m[4], &m[5]) == 6) { + uint8_t mac[6]; + for (int i = 0; i < 6; ++i) mac[i] = static_cast(m[i]); + /* Jaguar-3 bring-up never programs the self-MAC (0x0610), so the NDPA + * RA has nothing to match. Give the beamformee a known identity here so + * the sounder can address it; log it for the test harness. */ + static const uint8_t kBfeeMac[6] = {0x00, 0xe0, 0x4c, 0x88, 0x22, 0xce}; + for (uint16_t i = 0; i < 6; ++i) + _device.rtw_write8(0x0610 + i, kBfeeMac[i]); + devourer::bf::arm_beamformee(_device, mac, devourer::bf::kBfeeJaguar23); + _logger->info("Jaguar3 BF beamformee armed for beamformer {} — " + "beamformee MAC 00:e0:4c:88:22:ce", bfer); + } else { + _logger->error("DEVOURER_BF_ARM_BFEE — bad MAC '{}'", bfer); + } + } + _logger->info("Jaguar3: entering RX loop (kernel-style async URB queue)"); uint64_t frames = 0, reads = 0; /* Process one bulk-IN completion: walk the aggregated 8822C RX descriptors. */ diff --git a/tests/bf_ndpa_onair.sh b/tests/bf_ndpa_onair.sh new file mode 100755 index 0000000..e901db3 --- /dev/null +++ b/tests/bf_ndpa_onair.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# NDPA->NDP hardware-sounding on-air probe (question 1 of the BF self-sounding +# experiment): does setting the TX-descriptor NDPA bit make the MAC follow the +# injected NDPA control frame with a hardware-generated NDP? +# +# Runs two cells on the 8812AU (TX) + B210 (RX @ ch100 / 5500 MHz): +# A) baseline — NDPA frame injected, descriptor bit OFF -> single bursts +# B) test — NDPA frame injected, DEVOURER_TX_NDPA=1 -> burst PAIRS at +# a ~16 us SIFS gap if the sounding engine fires +# +# PASS = cell B pair_frac >> cell A pair_frac (and median pair gap ~10-30 us). +# +# Usage: sudo tests/bf_ndpa_onair.sh (from the devourer-bf worktree root) + +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(dirname "$HERE")" +TXDEMO="$ROOT/build/WiFiDriverTxDemo" +# UHD python is a system package (the tests/.venv predates python 3.14). +PY="/usr/bin/python3" +PROBE="$HERE/bf_ndpa_probe.py" +OUT="/tmp/bf-ndpa-probe" +CHANNEL=100 +FREQ=5500e6 +RATE=10e6 +DUR=8 + +[ -x "$TXDEMO" ] || { echo "build WiFiDriverTxDemo first ($TXDEMO)"; exit 1; } +[ -x "$PY" ] || { echo "missing venv python $PY"; exit 1; } +mkdir -p "$OUT" + +cleanup() { + pkill -x WiFiDriverTxDemo 2>/dev/null + pkill -f "bf_ndpa_probe.py" 2>/dev/null + wait 2>/dev/null +} +trap cleanup EXIT INT TERM + +run_cell() { # $1=name $2=ndpa_desc_bit(0|1) + local name="$1" bit="$2" + echo "== cell $name (desc NDPA bit=$bit) ==" + # SDR probe first so it sees the whole TX window. + "$PY" "$PROBE" --freq "$FREQ" --rate "$RATE" --duration "$((DUR - 1))" \ + > "$OUT/$name.probe" 2> "$OUT/$name.probe.err" & + local probe_pid=$! + sleep 1.5 # B210 tune/settle + + local env_extra=() + [ "$bit" = "1" ] && env_extra=(DEVOURER_TX_NDPA=1 DEVOURER_BF_ARM_SOUNDER=1) + env DEVOURER_PID=0x8812 DEVOURER_CHANNEL=$CHANNEL \ + DEVOURER_TX_RATE=VHT2SS_MCS0 \ + DEVOURER_TX_NDPA_RA=aa:bb:cc:dd:ee:ff \ + "${env_extra[@]}" \ + timeout "$DUR" "$TXDEMO" > "$OUT/$name.tx.log" 2>&1 + wait "$probe_pid" + grep "bf-probe summary" "$OUT/$name.probe" || echo "(no summary — see $OUT/$name.probe.err)" +} + +run_cell baseline 0 +sleep 2 +run_cell ndpa 1 + +echo +echo "== verdict ==" +a=$(grep -o 'pair_frac=[0-9.]*' "$OUT/baseline.probe" | cut -d= -f2) +b=$(grep -o 'pair_frac=[0-9.]*' "$OUT/ndpa.probe" | cut -d= -f2) +echo "baseline pair_frac=${a:-?} ndpa pair_frac=${b:-?} (logs in $OUT)" diff --git a/tests/bf_ndpa_probe.py b/tests/bf_ndpa_probe.py new file mode 100755 index 0000000..66bdab0 --- /dev/null +++ b/tests/bf_ndpa_probe.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""B210 burst-pair probe for the NDPA->NDP hardware-sounding experiment. + +Streams IQ from a UHD device, detects TX bursts by envelope threshold, and +reports each burst's duration plus the gap to the previous burst. The +signature of a working hardware sounding sequence is a *pair* of bursts per +injected NDPA — the NDPA PPDU, a SIFS (16 us), then the MAC-generated NDP — +where the baseline (descriptor NDPA bit off) shows isolated single bursts at +the txdemo inter-frame gap (~2 ms). + +Output (line-buffered): + bf-probe burst t= dur_us= gap_prev_us= + bf-probe summary bursts= pairs= singles= pair_frac= \ + median_pair_gap_us= median_dur_us= + +A "pair" = consecutive bursts with gap in [4, 60] us (SIFS-ish, well below +the inter-injection gap). Power thresholding is relative: 12 dB above the +per-chunk median (idle floor), bursts 10..400 us kept. + +Run: .venv/bin/python bf_ndpa_probe.py --freq 5500e6 --rate 10e6 --duration 10 +""" +from __future__ import annotations + +import argparse +import sys + +import numpy as np + +try: + import uhd +except ImportError: + sys.stderr.write( + "bf_ndpa_probe: `import uhd` failed. Use the tests/.venv created with " + "`uv venv --system-site-packages` (UHD python is a system package).\n" + ) + raise + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--freq", type=float, default=5500e6) + ap.add_argument("--rate", type=float, default=10e6) + ap.add_argument("--gain", type=float, default=40.0) + ap.add_argument("--antenna", default="RX2") + ap.add_argument("--args", default="", help="UHD device args") + ap.add_argument("--duration", type=float, default=10.0) + ap.add_argument("--thresh-db", type=float, default=12.0, + help="burst threshold above per-chunk median floor (dB)") + ap.add_argument("--pair-gap-us", type=float, nargs=2, default=[4.0, 60.0], + help="gap window (us) classifying a burst pair") + args = ap.parse_args() + + usrp = uhd.usrp.MultiUSRP(args.args) + usrp.set_rx_rate(args.rate) + usrp.set_rx_freq(uhd.types.TuneRequest(args.freq)) + usrp.set_rx_gain(args.gain) + usrp.set_rx_antenna(args.antenna) + + st_args = uhd.usrp.StreamArgs("fc32", "sc16") + rx = usrp.get_rx_stream(st_args) + md = uhd.types.RXMetadata() + cmd = uhd.types.StreamCMD(uhd.types.StreamMode.start_cont) + cmd.stream_now = True + rx.issue_stream_cmd(cmd) + + chunk = int(args.rate / 10) # 100 ms + buf = np.empty(chunk, dtype=np.complex64) + us_per_samp = 1e6 / args.rate + + total = 0 + in_burst = False + burst_start = 0 # absolute sample index + hang = 0 + HANG_MAX = max(1, int(2.0 / us_per_samp)) # 2 us dropout tolerance + last_burst_end = None + last_burst_t = None + bursts = [] # (abs_start, dur_us, gap_prev_us) + + n_target = int(args.duration * args.rate) + while total < n_target: + n = rx.recv(buf, md, timeout=2.0) + if n == 0: + continue + if md.error_code not in (uhd.types.RXMetadataErrorCode.none, + uhd.types.RXMetadataErrorCode.overflow): + sys.stderr.write(f"bf_ndpa_probe: rx error {md.error_code}\n") + x = buf[:n] + p = (x.real * x.real + x.imag * x.imag) + floor = float(np.median(p)) + 1e-12 + thr = floor * (10.0 ** (args.thresh_db / 10.0)) + above = p > thr + for i in range(n): + if above[i]: + if not in_burst: + in_burst = True + burst_start = total + i + hang = 0 + elif in_burst: + hang += 1 + if hang > HANG_MAX: + end = total + i - hang + dur_us = (end - burst_start) * us_per_samp + gap_us = ((burst_start - last_burst_end) * us_per_samp + if last_burst_end is not None else -1.0) + if 10.0 <= dur_us <= 400.0: + t = burst_start / args.rate + bursts.append((burst_start, dur_us, gap_us)) + print(f"bf-probe burst t={t:.6f} dur_us={dur_us:.1f} " + f"gap_prev_us={gap_us:.1f}", flush=True) + last_burst_end = end + in_burst = False + hang = 0 + total += n + + stop = uhd.types.StreamCMD(uhd.types.StreamMode.stop_cont) + rx.issue_stream_cmd(stop) + + lo, hi = args.pair_gap_us + pair_gaps = [g for (_, _, g) in bursts if lo <= g <= hi] + n_pairs = len(pair_gaps) + n_singles = len(bursts) - 2 * n_pairs + durs = [d for (_, d, _) in bursts] + frac = (2.0 * n_pairs / len(bursts)) if bursts else 0.0 + med_gap = float(np.median(pair_gaps)) if pair_gaps else -1.0 + med_dur = float(np.median(durs)) if durs else -1.0 + print(f"bf-probe summary bursts={len(bursts)} pairs={n_pairs} " + f"singles={n_singles} pair_frac={frac:.3f} " + f"median_pair_gap_us={med_gap:.1f} median_dur_us={med_dur:.1f}", + flush=True) + + # Chain histogram: group consecutive bursts whose gap <= hi into chains. + # len-2 chain = NDPA+NDP; len-3 = NDPA+NDP+CSI-report (beamformee reply). + chains = {} + cur = 1 + for (_, _, g) in bursts[1:]: + if 0 <= g <= hi: + cur += 1 + else: + chains[cur] = chains.get(cur, 0) + 1 + cur = 1 + chains[cur] = chains.get(cur, 0) + 1 + hist = " ".join(f"len{k}={v}" for k, v in sorted(chains.items())) + n3 = sum(v for k, v in chains.items() if k >= 3) + total_chains = sum(chains.values()) + print(f"bf-probe chains total={total_chains} {hist} " + f"triple_frac={n3 / total_chains if total_chains else 0.0:.3f}", + flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/bf_report_sniff.sh b/tests/bf_report_sniff.sh new file mode 100755 index 0000000..0ca383f --- /dev/null +++ b/tests/bf_report_sniff.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# BF self-sounding — decode-level confirmation (question 2, gold standard). +# Three adapters on ch100: +# - 8812AU : sounder (TX NDPA + descriptor NDPA bit + arm sounder engine) +# - 8821AU : beamformee (armed, unassociated) +# - 8814AU : passive monitor sniffer, DEVOURER_BF_DETECT_REPORT=1 +# A line on the sniffer with SA = 8821AU MAC is the +# direct proof the unassociated responder emitted a VHT Compressed Beamforming +# report. Run armed vs unarmed to show the report only appears when armed. +# +# Usage: sudo tests/bf_report_sniff.sh (from the devourer-bf worktree) + +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(dirname "$HERE")" +TXDEMO="$ROOT/build/WiFiDriverTxDemo" +RXDEMO="$ROOT/build/WiFiDriverDemo" +OUT="/tmp/bf-report-sniff" +CHANNEL=100 +DUR=8 +BFER_MAC="57:42:75:05:d6:00" # canonical SA = NDPA TA (8812AU sounder) + +[ -x "$TXDEMO" ] && [ -x "$RXDEMO" ] || { echo "build demos first"; exit 1; } +mkdir -p "$OUT" + +cleanup() { + pkill -x WiFiDriverTxDemo 2>/dev/null + pkill -x WiFiDriverDemo 2>/dev/null + wait 2>/dev/null +} +trap cleanup EXIT INT TERM + +echo "== read 8821AU (beamformee) MAC ==" +env DEVOURER_VID=0x2357 DEVOURER_PID=0x0120 DEVOURER_CHANNEL=$CHANNEL \ + timeout 25 "$RXDEMO" > "$OUT/mac_scan.log" 2>&1 +BFEE_MAC=$(grep -o "REG_MACID programmed from EFUSE: [0-9a-f:]*" "$OUT/mac_scan.log" \ + | awk '{print $NF}') +[ -n "$BFEE_MAC" ] || { echo "no 8821AU MAC — see $OUT/mac_scan.log"; exit 1; } +echo "8821AU MAC = $BFEE_MAC" +sleep 2 + +run_cell() { # $1=name $2=arm(0|1) + local name="$1" arm="$2" + echo "== cell $name (bfee armed=$arm) ==" + + # 8814AU sniffer (passive, whole cell). + env DEVOURER_PID=0x8813 DEVOURER_CHANNEL=$CHANNEL \ + DEVOURER_BF_DETECT_REPORT=1 \ + timeout $((DUR + 30)) "$RXDEMO" > "$OUT/$name.sniff.log" 2>&1 & + local sniff_pid=$! + for _ in $(seq 50); do + grep -q "In Monitor Mode" "$OUT/$name.sniff.log" && break; sleep 0.5 + done + + # 8821AU beamformee. + local bfee_env=(DEVOURER_VID=0x2357 DEVOURER_PID=0x0120 DEVOURER_CHANNEL=$CHANNEL) + [ "$arm" = "1" ] && bfee_env+=(DEVOURER_BF_ARM_BFEE="$BFER_MAC") + env "${bfee_env[@]}" timeout $((DUR + 8)) "$RXDEMO" \ + > "$OUT/$name.bfee.log" 2>&1 & + local bfee_pid=$! + for _ in $(seq 40); do + grep -q "In Monitor Mode" "$OUT/$name.bfee.log" && break; sleep 0.5 + done + sleep 1 + + # 8812AU sounder. + env DEVOURER_PID=0x8812 DEVOURER_CHANNEL=$CHANNEL \ + DEVOURER_TX_RATE=VHT2SS_MCS0 \ + DEVOURER_TX_NDPA_RA="$BFEE_MAC" \ + DEVOURER_TX_NDPA=1 DEVOURER_BF_ARM_SOUNDER=1 \ + timeout "$DUR" "$TXDEMO" > "$OUT/$name.tx.log" 2>&1 + + kill "$bfee_pid" "$sniff_pid" 2>/dev/null + wait "$bfee_pid" "$sniff_pid" 2>/dev/null + + local n + n=$(grep -c "" "$OUT/$name.sniff.log") + echo " bf-report lines: $n" + grep "" "$OUT/$name.sniff.log" | head -3 +} + +run_cell unarmed 0 +sleep 2 +run_cell armed 1 + +echo +echo "== verdict ==" +echo "unarmed reports: $(grep -c '' "$OUT/unarmed.sniff.log")" +echo "armed reports: $(grep -c '' "$OUT/armed.sniff.log")" +echo "(expect armed reports with sa=$BFEE_MAC; logs in $OUT)" diff --git a/tests/bf_selfsound_jaguar3.sh b/tests/bf_selfsound_jaguar3.sh new file mode 100755 index 0000000..203e8a3 --- /dev/null +++ b/tests/bf_selfsound_jaguar3.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# BF self-sounding cross-generation test: does a Jaguar-3 (8822C/8822E) +# beamformee, armed with the shared BeamformingSounder recipe, hardware-reply +# a VHT Compressed Beamforming report to an 8812AU (Jaguar-1) beamformer — +# with no association? +# +# 8812AU : sounder (proven Jaguar-1 path) +# 8822C/E : beamformee (shared recipe, kBfeeJaguar23) — the chip under test +# 8814AU : passive monitor sniffer, DEVOURER_BF_DETECT_REPORT=1 +# +# PASS = with SA = the Jaguar-3 beamformee MAC +# (00:e0:4c:88:22:ce, programmed at arm time) only when armed. +# +# Usage: sudo tests/bf_selfsound_jaguar3.sh (c812 | a81a) + +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(dirname "$HERE")" +TXDEMO="$ROOT/build/WiFiDriverTxDemo" +RXDEMO="$ROOT/build/WiFiDriverDemo" +OUT="/tmp/bf-jaguar3" +CHANNEL=100 +DUR=8 +BFER_MAC="57:42:75:05:d6:00" # 8812AU sounder = NDPA TA +BFEE_MAC="00:e0:4c:88:22:ce" # programmed onto the Jaguar-3 beamformee +BFEE_PID="${1:-0xc812}" # c812 = 8822CU, a81a = 8822EU + +[ -x "$TXDEMO" ] && [ -x "$RXDEMO" ] || { echo "build demos first"; exit 1; } +mkdir -p "$OUT" +tag="$(echo "$BFEE_PID" | tr -d 'x')" + +cleanup() { + pkill -x WiFiDriverTxDemo 2>/dev/null + pkill -x WiFiDriverDemo 2>/dev/null + wait 2>/dev/null +} +trap cleanup EXIT INT TERM + +run_cell() { # $1=name $2=arm(0|1) + local name="$1" arm="$2" + echo "== cell $name (Jaguar-3 bfee armed=$arm, pid=$BFEE_PID) ==" + + # Sniffer (whole cell) — 8814AU at ch100 (the 8812AU is the sounder, the + # 8821AU is free but either works per the README 5GHz benchmarks). It sits + # on a different USB bus from the Jaguar-3 chip. + env DEVOURER_PID=0x8813 DEVOURER_CHANNEL=$CHANNEL DEVOURER_BF_DETECT_REPORT=1 \ + timeout $((DUR + 90)) "$RXDEMO" > "$OUT/$name.sniff.log" 2>&1 & + local sniff_pid=$! + # RX demo signals monitor-ready with "Listening air..." (the Init path); + # "In Monitor Mode" is only the TX/InitWrite banner. + for _ in $(seq 40); do + grep -q "Listening air" "$OUT/$name.sniff.log" && break; sleep 0.5; done + + # Jaguar-3 beamformee. + local be=(DEVOURER_PID=$BFEE_PID DEVOURER_CHANNEL=$CHANNEL) + [ "$arm" = 1 ] && be+=(DEVOURER_BF_ARM_BFEE="$BFER_MAC") + env "${be[@]}" timeout $((DUR + 12)) "$RXDEMO" > "$OUT/$name.bfee.log" 2>&1 & + local bfee_pid=$! + # Jaguar-3 init is long (DLFW + cals); wait for the RX loop banner. + for _ in $(seq 80); do + grep -q "entering RX loop" "$OUT/$name.bfee.log" && break; sleep 0.5; done + grep -q "armed" "$OUT/$name.bfee.log" && echo " (bfee arm logged)" + sleep 1 + + # 8812AU sounder. + env DEVOURER_PID=0x8812 DEVOURER_CHANNEL=$CHANNEL DEVOURER_TX_RATE=VHT2SS_MCS0 \ + DEVOURER_TX_NDPA_RA="$BFEE_MAC" DEVOURER_TX_NDPA=1 DEVOURER_BF_ARM_SOUNDER=1 \ + timeout "$DUR" "$TXDEMO" > "$OUT/$name.tx.log" 2>&1 + + kill "$bfee_pid" "$sniff_pid" 2>/dev/null + wait "$bfee_pid" "$sniff_pid" 2>/dev/null + local n + n=$(grep -c "" "$OUT/$name.sniff.log") + echo " bf-report lines: $n" + grep "" "$OUT/$name.sniff.log" | grep "$BFEE_MAC" | head -2 +} + +run_cell "${tag}_unarmed" 0 +sleep 2 +run_cell "${tag}_armed" 1 + +echo +echo "== verdict ($BFEE_PID) ==" +echo "unarmed reports from bfee: $(grep '' "$OUT/${tag}_unarmed.sniff.log" | grep -c "$BFEE_MAC")" +echo "armed reports from bfee: $(grep '' "$OUT/${tag}_armed.sniff.log" | grep -c "$BFEE_MAC")" +echo "(logs in $OUT)" diff --git a/tests/bf_selfsound_onair.sh b/tests/bf_selfsound_onair.sh new file mode 100755 index 0000000..0cb6d0d --- /dev/null +++ b/tests/bf_selfsound_onair.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# BF self-sounding on-air probe (question 2): does an UNASSOCIATED, armed +# beamformee (8821AU) hardware-reply a VHT Compressed Beamforming report to +# our NDPA+NDP (8812AU)? +# +# Detector: B210 burst-chain analysis. Q1 established NDPA+NDP = len-2 chains +# at SIFS gaps. A responding beamformee turns them into len-3 chains +# (NDPA + NDP + report). Cells: +# A) bfee NOT armed -> len2 chains dominate, triple_frac ~ 0 +# B) bfee armed -> triple_frac >> 0 if the responder fires +# +# Usage: sudo tests/bf_selfsound_onair.sh (from the devourer-bf worktree) + +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(dirname "$HERE")" +TXDEMO="$ROOT/build/WiFiDriverTxDemo" +RXDEMO="$ROOT/build/WiFiDriverDemo" +PY="/usr/bin/python3" +PROBE="$HERE/bf_ndpa_probe.py" +OUT="/tmp/bf-selfsound" +CHANNEL=100 +FREQ=5500e6 +RATE=10e6 +DUR=8 +BFER_MAC="57:42:75:05:d6:00" # canonical SA = NDPA TA + +[ -x "$TXDEMO" ] && [ -x "$RXDEMO" ] || { echo "build demos first"; exit 1; } +mkdir -p "$OUT" + +cleanup() { + pkill -x WiFiDriverTxDemo 2>/dev/null + pkill -x WiFiDriverDemo 2>/dev/null + pkill -f "bf_ndpa_probe.py" 2>/dev/null + wait 2>/dev/null +} +trap cleanup EXIT INT TERM + +# --- step 0: learn the 8821AU MAC (logged at init as REG_MACID) ------------ +echo "== step 0: read 8821AU MAC ==" +env DEVOURER_VID=0x2357 DEVOURER_PID=0x0120 DEVOURER_CHANNEL=$CHANNEL \ + timeout 25 "$RXDEMO" > "$OUT/mac_scan.log" 2>&1 +BFEE_MAC=$(grep -o "REG_MACID programmed from EFUSE: [0-9a-f:]*" "$OUT/mac_scan.log" \ + | awk '{print $NF}') +[ -n "$BFEE_MAC" ] || { echo "could not read 8821AU MAC — see $OUT/mac_scan.log"; exit 1; } +echo "8821AU MAC = $BFEE_MAC" +sleep 2 + +run_cell() { # $1=name $2=arm_bfee(0|1) + local name="$1" arm="$2" + echo "== cell $name (bfee armed=$arm) ==" + # Beamformee RX up first (arming happens in Init). + local bfee_env=(DEVOURER_VID=0x2357 DEVOURER_PID=0x0120 DEVOURER_CHANNEL=$CHANNEL) + [ "$arm" = "1" ] && bfee_env+=(DEVOURER_BF_ARM_BFEE="$BFER_MAC") + env "${bfee_env[@]}" timeout $((DUR + 22)) "$RXDEMO" \ + > "$OUT/$name.bfee.log" 2>&1 & + local bfee_pid=$! + # Wait for the bfee to reach monitor mode before sounding. + for _ in $(seq 40); do + grep -q "In Monitor Mode" "$OUT/$name.bfee.log" && break + sleep 0.5 + done + grep -q "In Monitor Mode" "$OUT/$name.bfee.log" || echo "warn: bfee init slow" + + "$PY" "$PROBE" --freq "$FREQ" --rate "$RATE" --duration $((DUR - 1)) \ + > "$OUT/$name.probe" 2> "$OUT/$name.probe.err" & + local probe_pid=$! + sleep 1.5 + + env DEVOURER_PID=0x8812 DEVOURER_CHANNEL=$CHANNEL \ + DEVOURER_TX_RATE=VHT2SS_MCS0 \ + DEVOURER_TX_NDPA_RA="$BFEE_MAC" \ + DEVOURER_TX_NDPA=1 DEVOURER_BF_ARM_SOUNDER=1 \ + timeout "$DUR" "$TXDEMO" > "$OUT/$name.tx.log" 2>&1 + wait "$probe_pid" + kill "$bfee_pid" 2>/dev/null + wait "$bfee_pid" 2>/dev/null + grep "bf-probe \(summary\|chains\)" "$OUT/$name.probe" || \ + echo "(no summary — see $OUT/$name.probe.err)" +} + +run_cell unarmed 0 +sleep 2 +run_cell armed 1 + +echo +echo "== verdict ==" +a=$(grep -o 'triple_frac=[0-9.]*' "$OUT/unarmed.probe" | cut -d= -f2) +b=$(grep -o 'triple_frac=[0-9.]*' "$OUT/armed.probe" | cut -d= -f2) +echo "unarmed triple_frac=${a:-?} armed triple_frac=${b:-?} (logs in $OUT)" diff --git a/tools/bf_report_decode.py b/tools/bf_report_decode.py new file mode 100644 index 0000000..55bc5cf --- /dev/null +++ b/tools/bf_report_decode.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +"""Decode 802.11ac VHT Compressed Beamforming reports into per-subcarrier CSI. + +Input: `HEX` lines (from WiFiDriverDemo with +DEVOURER_BF_DETECT_REPORT=4) on stdin or a file, or bare hex frames. + +For the two-adapter self-sounding path a 2-TX beamformer sounds a 1-RX +beamformee, so each report carries a per-subcarrier 2x1 steering vector +V(k) = h(k)* / ||h(k)|| where h(k) = [h_A(k), h_B(k)] is the per-tone channel +from the beamformer's two antennas. The compressed report encodes V(k) as +Givens angles (psi, phi) per subcarrier: + + |V(k)| = [cos(psi_k), sin(psi_k)], arg(V_B/V_A) = phi_k + +so psi_k = atan(|h_B|/|h_A|) and phi_k = arg(h_B/h_A) — the *relative* +per-subcarrier channel between the two TX antennas. Its variation across k is +the frequency-selective structure (a deep fade on one antenna rotates V). + +The angle bit-width is derived from the payload length and the subcarrier +count Ns (from BW + grouping Ng), then the (b_phi, b_psi) split is chosen by +cross-frame stability — the correct split makes psi(k)/phi(k) repeatable +across reports of a quasi-static channel; a wrong split looks like noise. + +Usage: + WiFiDriverDemo ... DEVOURER_BF_DETECT_REPORT=4 | tools/bf_report_decode.py + tools/bf_report_decode.py captured_frames.txt --csv out.csv +""" +from __future__ import annotations + +import argparse +import math +import sys + +# Ns = number of subcarriers carried in a VHT compressed BF report, per +# bandwidth (0..3 = 20/40/80/160 MHz) and grouping Ng (1/2/4). +_MSB = False + +NS_TABLE = { + 0: {1: 52, 2: 30, 4: 16}, + 1: {1: 108, 2: 58, 4: 30}, + 2: {1: 234, 2: 122, 4: 62}, + 3: {1: 468, 2: 244, 4: 124}, +} + + +class BitReader: + """Bit reader for the packed angle stream. 802.11 packs LSB-first; a + --msb flag flips it for cross-checking the decode.""" + + def __init__(self, data: bytes, msb: bool = False): + self.data = data + self.pos = 0 + self.msb = msb + + def read(self, n: int) -> int: + v = 0 + for i in range(n): + byte = self.data[self.pos >> 3] + if self.msb: + bit = (byte >> (7 - (self.pos & 7))) & 1 + v = (v << 1) | bit + else: + bit = (byte >> (self.pos & 7)) & 1 + v |= bit << i + self.pos += 1 + return v + + +def dequant_phi(q: int, b: int) -> float: + """phi in (0, 2*pi): phi = (2q+1) * pi / 2^b.""" + return (2 * q + 1) * math.pi / (1 << b) + + +def dequant_psi(q: int, b: int) -> float: + """psi in (0, pi/2): psi = (2q+1) * pi / 2^(b+2).""" + return (2 * q + 1) * math.pi / (1 << (b + 2)) + + +def parse_frame(hexstr: str): + """Return dict with header fields + raw angle bytes, or None if not a + VHT/HT compressed beamforming report.""" + try: + d = bytes.fromhex(hexstr) + except ValueError: + return None + if len(d) < 30: + return None + sub = d[0] & 0xF0 + if sub not in (0xD0, 0xE0): # Action / Action No-Ack + return None + cat, act = d[24], d[25] + if not ((cat == 0x15 or cat == 0x07) and act == 0x00): + return None + mc = d[26] | (d[27] << 8) | (d[28] << 16) # 24-bit LE MIMO control + nc = (mc & 0x7) + 1 + nr = ((mc >> 3) & 0x7) + 1 + bw = (mc >> 6) & 0x3 + ng_code = (mc >> 8) & 0x3 + ng = {0: 1, 1: 2, 2: 4, 3: 4}[ng_code] + codebook = (mc >> 10) & 0x1 + feedback = (mc >> 11) & 0x1 # 0 = SU, 1 = MU + sa = ":".join(f"{b:02x}" for b in d[10:16]) + snr = list(d[29:29 + nc]) # avg SNR per column (signed 0.25 dB) + angle_bytes = d[29 + nc:len(d) - 4] # drop 4-byte FCS + return dict(sa=sa, nc=nc, nr=nr, bw=bw, ng=ng, codebook=codebook, + feedback=feedback, snr=snr, angle_bytes=angle_bytes) + + +def angle_layout(nr: int, nc: int): + """Order of (kind, index) angles for the compressed matrix, per + 802.11 19.3.12.3.6. For each column i: (Nr-i) phi's then (Nr-i) psi's... + actually the standard interleaves D(phi) then G(psi) per Givens stage. + For the 2x1 case this is simply [phi, psi].""" + seq = [] + for i in range(1, min(nc, nr - 1) + 1): + for _ in range(i, nr): + seq.append("phi") + for _ in range(i, nr): + seq.append("psi") + return seq # 2x1 -> ["phi","psi"] + + +def decode_angles(angle_bytes: bytes, ns: int, na: int, bphi: int, bpsi: int, + layout): + """Unpack ns subcarriers x na angles. Returns list per subcarrier of + dict(phi=[...], psi=[...]) in radians, or None if the stream is too short.""" + need_bits = ns * (layout.count("phi") * bphi + layout.count("psi") * bpsi) + if need_bits > len(angle_bytes) * 8: + return None + br = BitReader(angle_bytes, _MSB) + out = [] + for _ in range(ns): + phi, psi = [], [] + for kind in layout: + if kind == "phi": + phi.append(dequant_phi(br.read(bphi), bphi)) + else: + psi.append(dequant_psi(br.read(bpsi), bpsi)) + out.append(dict(phi=phi, psi=psi)) + return out + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("infile", nargs="?", help="capture file (default stdin)") + ap.add_argument("--csv", help="write per-subcarrier CSV here") + ap.add_argument("--max-frames", type=int, default=200) + ap.add_argument("--msb", action="store_true", help="MSB-first bit order") + args = ap.parse_args() + + global _MSB + _MSB = args.msb + src = open(args.infile) if args.infile else sys.stdin + frames = [] + for line in src: + line = line.strip() + if "" in line: + line = line.split("", 1)[1] + f = parse_frame(line) + if f: + frames.append(f) + if len(frames) >= args.max_frames: + break + if not frames: + print("no VHT/HT compressed-beamforming reports found", file=sys.stderr) + return 1 + + f0 = frames[0] + nr, nc, bw, ng = f0["nr"], f0["nc"], f0["bw"], f0["ng"] + ns = NS_TABLE.get(bw, {}).get(ng) + layout = angle_layout(nr, nc) + na = len(layout) + nbits = len(f0["angle_bytes"]) * 8 + print(f"# {len(frames)} reports SA={f0['sa']} Nr={nr} Nc={nc} " + f"BW={20 << bw}MHz Ng={ng} codebook={f0['codebook']} " + f"feedback={'MU' if f0['feedback'] else 'SU'}") + if not ns: + print(f"# unknown Ns for BW={bw} Ng={ng}", file=sys.stderr) + return 1 + per_sc_bits = nbits / ns + print(f"# angle payload {len(f0['angle_bytes'])} B = {nbits} bits, " + f"Ns={ns} -> {per_sc_bits:.2f} bits/subcarrier over {na} angle(s)") + if per_sc_bits != int(per_sc_bits): + print("# WARN: non-integer bits/subcarrier — Ns or Na guess wrong", + file=sys.stderr) + per_sc_bits = int(per_sc_bits) + + # Candidate (bphi, bpsi) splits summing to the per-subcarrier budget for a + # 2x1 (one phi + one psi). Standard SU pairs are (7,5)/(9,7); Realtek's + # compact report may use a smaller codebook, so enumerate all splits and + # pick the one whose psi(k) is most stable across the (quasi-static) frames. + nphi, npsi = layout.count("phi"), layout.count("psi") + candidates = [] + for bphi in range(2, per_sc_bits - 1): + bpsi = (per_sc_bits - nphi * bphi) // npsi if npsi else 0 + if bpsi < 2: + continue + if nphi * bphi + npsi * bpsi != per_sc_bits: + continue + candidates.append((bphi, bpsi)) + if not candidates: + candidates = [(per_sc_bits // 2, per_sc_bits - per_sc_bits // 2)] + + def stability(bphi, bpsi): + """Mean cross-frame variance of psi[0] per subcarrier — lower is a + more self-consistent decode of a static channel.""" + cols = [[] for _ in range(ns)] + for f in frames: + dec = decode_angles(f["angle_bytes"], ns, na, bphi, bpsi, layout) + if dec is None: + return math.inf + for k in range(ns): + cols[k].append(dec[k]["psi"][0]) + var = 0.0 + for c in cols: + m = sum(c) / len(c) + var += sum((x - m) ** 2 for x in c) / len(c) + return var / ns + + def smoothness(bphi, bpsi): + """Lag-1 correlation of psi[0] across subcarriers, averaged over + frames. Real (band-limited) CSI is smooth vs frequency -> high + correlation; a wrong bit-split scrambles tones -> ~0.""" + tot, n = 0.0, 0 + for f in frames[:20]: + dec = decode_angles(f["angle_bytes"], ns, na, bphi, bpsi, layout) + if dec is None: + return -1.0 + x = [dec[k]["psi"][0] for k in range(ns)] + m = sum(x) / ns + xc = [v - m for v in x] + denom = sum(v * v for v in xc) or 1e-9 + num = sum(xc[i] * xc[i + 1] for i in range(ns - 1)) + tot += num / denom + n += 1 + return tot / n if n else -1.0 + + print("# candidate split validation (want low cross-frame var AND high " + "adjacent-tone corr):") + print("# b_phi b_psi xframe_var tone_corr") + scored = [] + for (bphi, bpsi) in candidates: + v = stability(bphi, bpsi) + s = smoothness(bphi, bpsi) + scored.append((bphi, bpsi, v, s)) + print(f"# {bphi:5d} {bpsi:5d} {v:10.5f} {s:+.3f}") + # Cross-frame variance is the reliable discriminator: the correct bit-split + # decodes a static channel to repeatable angles (low var); a wrong split + # smears the LSBs into different values frame-to-frame (high var). Tone + # correlation only helps on a *frequency-selective* channel — on a flat + # bench LOS link it is near zero for every split and can't disambiguate. + best = min(scored, key=lambda t: t[2]) + bphi, bpsi = best[0], best[1] + print(f"# chose b_phi={bphi} b_psi={bpsi} by min cross-frame var " + f"({best[2]:.5f}); phi=psi+2 matches the standard Givens structure. " + f"tone_corr={best[3]:+.3f}") + if best[3] < 0.3: + print("# NB: low tone correlation => channel ~flat across this BW " + "(short LOS / coherence-BW > channel-BW); per-tone structure is " + "quantisation-limited. Use wider BW or multipath to see " + "frequency selectivity.") + + # Average the decoded angles over all frames -> the per-tone channel shape. + acc = [dict(psi=0.0, phi=0.0) for _ in range(ns)] + psi_var = [0.0] * ns + psi_all = [[] for _ in range(ns)] + for f in frames: + dec = decode_angles(f["angle_bytes"], ns, na, bphi, bpsi, layout) + for k in range(ns): + acc[k]["psi"] += dec[k]["psi"][0] + acc[k]["phi"] += dec[k]["phi"][0] + psi_all[k].append(dec[k]["psi"][0]) + nfr = len(frames) + rows = [] + for k in range(ns): + psi = acc[k]["psi"] / nfr + phi = acc[k]["phi"] / nfr + ratio = math.tan(psi) # |h_B|/|h_A| + m = psi + var = sum((x - m) ** 2 for x in psi_all[k]) / nfr + rows.append((k, psi, phi, ratio, var)) + + # Avg SNR of space-time stream: int8, dB = 22 + 0.25*v -> range -10..53.75. + snr0 = frames[0]["snr"] + snr_db = [(22.0 + 0.25 * (b if b < 128 else b - 256)) for b in snr0] + print(f"# per-stream avg SNR (col dB): " + f"{', '.join(f'{s:.2f}' for s in snr_db)}") + print(f"# per-tone relative channel |h_B/h_A| across {ns} subcarriers " + f"(psi->amplitude ratio); '#'=strong on antenna B, '.'=on antenna A") + + # ASCII plot of the amplitude ratio across subcarriers (the frequency shape) + ratios = [r[3] for r in rows] + lo, hi = min(ratios), max(ratios) + span = (hi - lo) or 1.0 + ramp = " .:-=+*#%@" + line = "".join(ramp[min(len(ramp) - 1, + int((r - lo) / span * (len(ramp) - 1)))] + for r in ratios) + print(f" |h_B/h_A| [{lo:.2f}..{hi:.2f}]: {line}") + + if args.csv: + with open(args.csv, "w") as fh: + fh.write("subcarrier,psi_rad,phi_rad,ampl_ratio_hB_hA,psi_var\n") + for (k, psi, phi, ratio, var) in rows: + fh.write(f"{k},{psi:.5f},{phi:.5f},{ratio:.5f},{var:.6f}\n") + print(f"# wrote {args.csv}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/txdemo/main.cpp b/txdemo/main.cpp index 7758b28..bf82a7c 100644 --- a/txdemo/main.cpp +++ b/txdemo/main.cpp @@ -513,6 +513,37 @@ int main(int argc, char **argv) { } } + /* DEVOURER_TX_NDPA_RA=aa:bb:cc:dd:ee:ff — beamforming-sounding probe: + * replace the beacon with a 19-byte VHT NDP Announcement control frame + * (IEEE 802.11-2016 9.3.1.19) addressed to RA, TA = the canonical SA. + * Pair with DEVOURER_TX_NDPA=1 (library-side TX-descriptor NDPA bit, so + * the MAC auto-appends the hardware-generated NDP) and + * DEVOURER_TX_RATE=VHT2SS_MCS0 (sounding must be a VHT PPDU). + * STA Info = AID 0 (non-AP/unassociated), SU feedback, Nc index 0. */ + if (const char *ra_env = std::getenv("DEVOURER_TX_NDPA_RA")) { + unsigned ra[6]; + if (std::sscanf(ra_env, "%x:%x:%x:%x:%x:%x", &ra[0], &ra[1], &ra[2], + &ra[3], &ra[4], &ra[5]) != 6) { + logger->error("DEVOURER_TX_NDPA_RA — bad MAC '{}'", ra_env); + return 1; + } + std::vector ndpa(tx_buf.begin(), tx_buf.begin() + 10); // radiotap + const uint8_t ndpa_body[19] = { + 0x54, 0x00, /* FC: type=control, subtype=NDPA */ + 0x64, 0x00, /* duration ~100 us */ + static_cast(ra[0]), static_cast(ra[1]), + static_cast(ra[2]), static_cast(ra[3]), + static_cast(ra[4]), static_cast(ra[5]), + 0x57, 0x42, 0x75, 0x05, 0xd6, 0x00, /* TA = canonical SA */ + 0x04, /* sounding dialog token: seq=1, bits[1:0]=0 */ + 0x00, 0x00 /* STA Info: AID=0, SU feedback, Nc=0 */ + }; + ndpa.insert(ndpa.end(), ndpa_body, ndpa_body + sizeof(ndpa_body)); + tx_buf = std::move(ndpa); + logger->info("DEVOURER_TX_NDPA_RA — sending VHT NDPA to {} instead of " + "the beacon", ra_env); + } + /* Thermal monitoring — read inline on the TX (owning) thread, so no * background thread shares the libusb handle (no USB contention). Cadence is * derived from DEVOURER_THERMAL_POLL_MS over the ~2 ms/packet loop; 0 =