diff --git a/CMakeLists.txt b/CMakeLists.txt index 6b13328..6a464b7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -314,3 +314,16 @@ add_executable(BfReportDecodeSelftest target_link_libraries(BfReportDecodeSelftest PRIVATE WiFiDriver) add_test(NAME bf_report_decode COMMAND BfReportDecodeSelftest) + +# Headless guard for the per-tone interference localizer's detection + +# frequency-mapping math (tests/rx_tone_localize.py). Pure Python, no hardware: +# synthetic psi-variance spikes / SNR notches must localize to the right tone +# group. The RF-side capture path is validated out-of-band (tests/rx_tone_localize.sh). +find_package(Python3 COMPONENTS Interpreter) +if(Python3_Interpreter_FOUND) + add_test( + NAME rx_tone_localize_math + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/rx_tone_localize.py --self-test + ) +endif() diff --git a/README.md b/README.md index 49dafeb..7d8de4a 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,36 @@ Common to both demos: to DEBUG (produces ~7 MB per 15 s, can fill `/tmp` mid-capture, and slows init measurably). `DEVOURER_USB_QUIET` is accepted as a no-op. +`WiFiDriverDemo` (RX)-only knobs: + +- `DEVOURER_RX_ENERGY_MS=N` — frame-free RX energy / interferer-detection + telemetry (the read side of `DEVOURER_CW_TONE`). Every `N` ms emit a + `` line combining the chip's phydm false-alarm + CCA + (channel-busy) counters and DIG IGI (`IRtlDevice::GetRxEnergy`, frame-free, + all three generations) with a rolling per-frame RSSI/SNR aggregate. A second + adapter running this detects the first adapter's CW carrier: co-located, a + strong tone pushes `cca_ofdm` far out of its ambient band — a large spike (the + 2T2R 8822CU registers the carrier as busy) or a collapse toward zero (the + 1T1R parts' AGC saturates and RX goes deaf). No SDR needed. 0 = disabled. + Validate with `tests/rx_energy_probe.sh` (+ `tests/rx_energy_check.py`). + Reads channel-wide scalars, not per-subcarrier CSI (no Realtek 88xx chip + exports CSI to the host); build a coarse spectrum by sweeping channels. + Each interval also emits a `` line — the frame-free phydm NHM + (noise histogram): a 12-bucket, IGI-referenced in-band power distribution + (`peak` = fullest bucket, `busy` = percent above the noise floor, `hist` = + the raw counts low→high power). An in-band interferer shifts the histogram's + mass into higher buckets (measured: peak bucket 5→8 on the 8822CU under a + co-located CW tone). All three generations. +- `DEVOURER_RX_SWEEP="1,6,11"` — coarse **live spectrum map**. Cycle the listed + channels, dwelling `DEVOURER_RX_SWEEP_DWELL_MS` (default 300) on each, and emit + one `ch=N` line per bin. The RX loop runs on a worker thread + while the main thread retunes (`SetMonitorChannel`) between reads — one process, + uniform across all three generations. Park a `DEVOURER_CW_TONE` on one channel + (another adapter) and the map peaks (or, on the saturating 1T1R parts, dips) at + that channel. Resolution = the channel grid (20 MHz), down to ~5 MHz on Jaguar3 + with `DEVOURER_NB_BW=5`. Render with `tests/rx_spectrum_sweep.sh` + + `tests/rx_spectrum_sweep.py`. + `WiFiDriverTxDemo`-only knobs: - `DEVOURER_TX_RATE=[/][/SGI][/LDPC][/STBC]` — the on-air TX mode, diff --git a/demo/main.cpp b/demo/main.cpp index d125b8c..8dc6839 100644 --- a/demo/main.cpp +++ b/demo/main.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -129,6 +130,93 @@ static const std::vector g_csi_selectors = []() -> std::vector line combining the chip's phydm FA/CCA counters + IGI + * (IRtlDevice::GetRxEnergy, frame-free, all three generations) with a rolling + * per-frame RSSI/SNR aggregate. A second adapter running this detects the first + * adapter's CW carrier as a jump in cca_ofdm / fa_ofdm and a rise in igi. + * 0 = disabled. */ +static const uint32_t g_rx_energy_ms = []() -> uint32_t { + const char *e = std::getenv("DEVOURER_RX_ENERGY_MS"); + return (e && *e) ? static_cast(std::strtoul(e, nullptr, 0)) : 0; +}(); + +/* DEVOURER_RX_SWEEP="1,6,11": live coarse spectrum sweep. Cycle the listed + * channels, dwelling DEVOURER_RX_SWEEP_DWELL_MS (default 300) on each, and emit + * one ch=N line per bin. The RX loop runs on a worker thread so + * the main thread is free to retune (SetMonitorChannel) between reads — a live + * energy-vs-frequency map that localizes an interferer (peaks, or dips on the + * 1T1R parts that saturate, at the tone's channel). Empty = disabled. */ +static const std::vector g_rx_sweep = []() -> std::vector { + std::vector out; + const char *e = std::getenv("DEVOURER_RX_SWEEP"); + if (!e || !*e) return out; + std::string s = e; + size_t pos = 0; + while (pos < s.size()) { + size_t c = s.find(',', pos); + std::string tok = + s.substr(pos, c == std::string::npos ? std::string::npos : c - pos); + if (!tok.empty()) out.push_back(std::atoi(tok.c_str())); + if (c == std::string::npos) break; + pos = c + 1; + } + return out; +}(); +static const uint32_t g_rx_sweep_dwell_ms = []() -> uint32_t { + const char *e = std::getenv("DEVOURER_RX_SWEEP_DWELL_MS"); + return (e && *e) ? static_cast(std::strtoul(e, nullptr, 0)) : 300; +}(); + +/* Rolling per-frame RSSI/SNR aggregate (the frame-driven signal, all gens): + * updated per received frame in packetProcessor, drained each interval by the + * energy emitter. */ +static std::mutex g_rxagg_mu; +struct RxAgg { + uint32_t n = 0; + int32_t rssi_sum = 0, rssi_max = -128, snr_sum = 0, snr_min = 127; + void add(int rssi, int snr) { + ++n; + rssi_sum += rssi; + if (rssi > rssi_max) rssi_max = rssi; + snr_sum += snr; + if (snr < snr_min) snr_min = snr; + } +}; +static RxAgg g_rxagg; + +/* Emit the frame-free NHM power histogram (IRtlDevice::GetRxEnergy fills it) as + * a distinct line so it never disturbs the + * format its regex consumers key on. `peak` = the fullest bucket (0 = quiet + * noise floor, higher = energy is landing in a higher power band, e.g. under an + * interferer); `busy` = percent of samples above the lowest bucket; `hist` = + * the 12 raw bucket counts (IGI-referenced, low→high power). ch<0 omits the + * channel field (steady-state emitter); ch>=0 tags it (sweep). */ +static void emit_nhm(const RxEnergy &e, int ch) { + if (!e.valid_nhm) + return; + uint32_t total = 0, peak = 0; + int peak_k = 0; + for (int k = 0; k < 12; k++) { + total += e.nhm[k]; + if (e.nhm[k] > peak) { peak = e.nhm[k]; peak_k = k; } + } + int busy = total ? static_cast(100 * (total - e.nhm[0]) / total) : 0; + char hist[96]; + int off = 0; + for (int k = 0; k < 12; k++) + off += std::snprintf(hist + off, sizeof(hist) - off, "%s%u", + k ? "," : "", e.nhm[k]); + if (ch >= 0) + printf("ch=%d peak=%d busy=%d dur=%u hist=%s\n", ch, peak_k, + busy, e.nhm_duration, hist); + else + printf("peak=%d busy=%d dur=%u hist=%s\n", peak_k, busy, + e.nhm_duration, hist); + fflush(stdout); +} + static void packetProcessor(const Packet &packet) { /* C2H packets carry chip-side status updates, not 802.11 frames. Handle * them up front so the rest of this function (which assumes a normal @@ -166,6 +254,13 @@ static void packetProcessor(const Packet &packet) { ++g_rx_count; + /* Feed the rolling per-frame RSSI/SNR aggregate for DEVOURER_RX_ENERGY_MS + * (the frame-driven half of the energy telemetry). path-A chain. */ + if (g_rx_energy_ms > 0) { + std::lock_guard lk(g_rxagg_mu); + g_rxagg.add(packet.RxAtrib.rssi[0], packet.RxAtrib.snr[0]); + } + if (g_rx_count == 1) { printf("init-timing: demo.first_rx_frame = %lld ms\n", ms_since_start()); @@ -545,6 +640,59 @@ int main() { }); } #endif /* DEVOURER_HAVE_JAGUAR1 */ + + /* DEVOURER_RX_ENERGY_MS: frame-free RX energy / channel-busy telemetry — the + * read side of DEVOURER_CW_TONE. Cross-generation (IRtlDevice::GetRxEnergy), + * so it runs off the base device pointer, not the Jaguar1 downcast. The thread + * sleeps one interval first (so its first read lands after bring-up completes, + * not mid-init), then each interval reads GetRxEnergy() + drains the rolling + * frame aggregate and emits one line. Concurrency caveat: + * the FA/CCA reads share libusb with the RX bulk loop (like the thermal + * poller) — keep the cadence conservative (>= a few hundred ms). */ + std::atomic energy_emitter_stop{false}; + std::thread energy_emitter; + if (g_rx_energy_ms > 0) { + logger->info("DEVOURER_RX_ENERGY_MS={} — starting RX energy telemetry", + g_rx_energy_ms); + IRtlDevice *dev = rtlDevice.get(); + energy_emitter = std::thread([&energy_emitter_stop, dev]() { + auto nap = [&](uint32_t ms) { + for (uint32_t s = 0; s < ms && !energy_emitter_stop.load(); s += 50) + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + }; + nap(g_rx_energy_ms); /* let bring-up finish before the first read */ + while (!energy_emitter_stop.load()) { + RxEnergy e = dev->GetRxEnergy(); + RxAgg agg; + { + std::lock_guard lk(g_rxagg_mu); + agg = g_rxagg; + g_rxagg = RxAgg{}; + } + char fao[16], fac[16], cco[16], ccc[16], igi[16]; + auto u = [](char *b, bool v, uint32_t x) { + if (v) std::snprintf(b, 16, "%u", x); else std::snprintf(b, 16, "-"); + }; + u(fao, e.valid_fa, e.fa_ofdm); + u(fac, e.valid_fa, e.fa_cck); + u(cco, e.valid_fa, e.cca_ofdm); + u(ccc, e.valid_fa, e.cca_cck); + if (e.valid_igi) std::snprintf(igi, 16, "%u", e.igi); + else std::snprintf(igi, 16, "-"); + int rssi_mean = agg.n ? agg.rssi_sum / static_cast(agg.n) : 0; + int snr_mean = agg.n ? agg.snr_sum / static_cast(agg.n) : 0; + printf("cca_ofdm=%s cca_cck=%s fa_ofdm=%s fa_cck=%s " + "igi=%s frames=%u rssi_mean=%d rssi_max=%d snr_mean=%d " + "snr_min=%d\n", + cco, ccc, fao, fac, igi, agg.n, rssi_mean, + agg.n ? agg.rssi_max : 0, snr_mean, agg.n ? agg.snr_min : 0); + fflush(stdout); + emit_nhm(e, -1); + nap(g_rx_energy_ms); + } + }); + } + /* Default channel 36 (5 GHz) for the 8812 reference. Override with * DEVOURER_CHANNEL=N env var (e.g. DEVOURER_CHANNEL=6 for busy 2.4 GHz). */ int channel = 36; @@ -577,12 +725,74 @@ int main() { width = CHANNEL_WIDTH_10; logger->info("DEVOURER_NB_BW={} — RX bandwidth {} MHz", nb, mhz); } + + /* DEVOURER_RX_SWEEP: live spectrum sweep. Run the (blocking) RX bring-up + + * loop on a worker thread so the main thread is free to retune between energy + * reads. StopRxLoop unblocks it on SIGINT. */ + if (!g_rx_sweep.empty()) { + logger->info("DEVOURER_RX_SWEEP: {} bins, dwell {} ms — live spectrum map", + g_rx_sweep.size(), g_rx_sweep_dwell_ms); + IRtlDevice *dev = rtlDevice.get(); + SelectedChannel first{static_cast(g_rx_sweep[0]), ch_offset, width}; + std::thread rx([dev, first]() { + try { + dev->Init(packetProcessor, first); + } catch (const std::exception &e) { + printf("RX-sweep bring-up failed: %s\n", e.what()); + fflush(stdout); + } + }); + /* Let bring-up complete before the first retune. */ + for (int s = 0; s < 2500 && !g_devourer_should_stop; s += 50) + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + size_t bi = 0; + while (!g_devourer_should_stop) { + int ch = g_rx_sweep[bi % g_rx_sweep.size()]; + ++bi; + dev->SetMonitorChannel( + SelectedChannel{static_cast(ch), ch_offset, width}); + for (uint32_t s = 0; s < g_rx_sweep_dwell_ms && !g_devourer_should_stop; + s += 50) + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + RxEnergy e = dev->GetRxEnergy(); + char cco[16], ccc[16], fao[16], fac[16], igi[16]; + auto u = [](char *b, bool v, uint32_t x) { + if (v) std::snprintf(b, 16, "%u", x); else std::snprintf(b, 16, "-"); + }; + u(cco, e.valid_fa, e.cca_ofdm); + u(ccc, e.valid_fa, e.cca_cck); + u(fao, e.valid_fa, e.fa_ofdm); + u(fac, e.valid_fa, e.fa_cck); + u(igi, e.valid_igi, e.igi); + printf("ch=%d cca_ofdm=%s cca_cck=%s fa_ofdm=%s " + "fa_cck=%s igi=%s\n", + ch, cco, ccc, fao, fac, igi); + fflush(stdout); + emit_nhm(e, ch); + } + dev->StopRxLoop(); + if (rx.joinable()) + rx.join(); + dev->Stop(); + rc = libusb_release_interface(dev_handle, 0); + if (rc != 0) + logger->info("libusb_release_interface rc={}", rc); + libusb_close(dev_handle); + libusb_exit(ctx); + return 0; + } + rtlDevice->Init(packetProcessor, SelectedChannel{ .Channel = static_cast(channel), .ChannelOffset = ch_offset, .ChannelWidth = width, }); + /* Stop the energy telemetry thread before de-init (it reads chip registers). */ + energy_emitter_stop = true; + if (energy_emitter.joinable()) + energy_emitter.join(); + /* Clean chip de-init before dropping the interface (card-disable PWR_SEQ), so * the adapter re-enumerates instead of hanging its USB core. */ rtlDevice->Stop(); diff --git a/docs/rx-spectrum-sensing.md b/docs/rx-spectrum-sensing.md new file mode 100644 index 0000000..fc3dcca --- /dev/null +++ b/docs/rx-spectrum-sensing.md @@ -0,0 +1,141 @@ +# RX spectrum sensing / interferer detection + +The inverse of `DEVOURER_CW_TONE`: use the adapter as a coarse **energy sensor** +to detect an in-channel interferer — no SDR, two adapters (one emits a tone, one +senses). + +## What the silicon can and can't give you + +No Realtek 88xx chip (Jaguar1/2/3 — 8812AU/8814AU/8821AU/8822BU/8821CU/8822CU/ +8822EU) exports raw **per-subcarrier CSI** to the host. The beamforming CSI is +computed in the BB and transmitted over the air as a compressed report; there is +no DMA readback of the channel matrix. So a true per-tone FFT of the receive +spectrum is not available on this hardware. + +What *is* available is **scalar, channel-wide** energy: + +- **phydm false-alarm (FA) + CCA (clear-channel-assessment / channel-busy) + counters**, and the **DIG initial-gain (IGI)** noise-floor proxy. These are + read frame-free (no received frame required) and increment with in-band energy + and channel activity. +- **NHM (noise histogram)** — a frame-free in-band **power distribution**: the BB + bins received power into 12 IGI-referenced buckets over a short measurement + window. Richer than the scalar counters — it shows *where* in power the energy + sits, so a rising interferer moves the histogram's mass into higher buckets + without needing a sweep. Ported from phydm CCX across all three generations + (11AC register map for Jaguar1/2, the newer JGR3 map for Jaguar3). +- **per-frame per-chain RSSI / SNR / EVM** — link-quality scalars averaged over + the whole channel, available only on frames that arrive. + +To turn scalar energy into a coarse *spectrum*, sweep the channel/bandwidth and +sample the energy per bin (narrowband down to 5 MHz on Jaguar3). Per-tone +interference localisation is possible through a different mechanism entirely — +the self-sounding beamforming report (see `docs/beamforming-self-sounding.md`), +whose per-tone SNR / V-angle variance localises an interferer to ~1 MHz. + +## `DEVOURER_RX_ENERGY_MS` — the energy sensor + +`WiFiDriverDemo` with `DEVOURER_RX_ENERGY_MS=N` emits one `` +line every `N` ms: + +``` +cca_ofdm=.. cca_cck=.. fa_ofdm=.. fa_cck=.. igi=.. frames=N + rssi_mean=.. rssi_max=.. snr_mean=.. snr_min=.. +``` + +`cca_*`/`fa_*`/`igi` are frame-free (`IRtlDevice::GetRxEnergy`); the FA/CCA counts +are the delta since the previous line (each read resets the hardware counters). +`rssi_*`/`snr_*`/`frames` are the rolling per-frame aggregate over the interval. + +The same call also fills the **NHM power histogram**, emitted as a companion +`` line (kept on a distinct tag so the `` format +its regex consumers key on is untouched): + +``` +peak=.. busy=.. dur=.. hist=b0,b1,..,b11 +``` + +`peak` is the fullest bucket (0 = noise floor, higher = energy in a higher power +band), `busy` the percent of samples above the lowest bucket, `hist` the 12 raw +IGI-referenced counts (low→high power). A frame-free measurement: the driver sets +11 thresholds, pulses a trigger, polls a ready bit, and reads 12 counters. + +The facilities differ by generation but all three read the same fields: + +| Generation | FA/CCA/IGI + NHM | register map | +|---|---|---| +| Jaguar1 (8812/8821/8814) | yes | classic AC — FA 0xF48/0xA5C, CCA 0xF08, IGI 0xC50; NHM 0x994/0x990/0x998/0xfa8/0xfb4 | +| Jaguar2 (8822BU/8821CU) | yes | classic AC (FA/CCA sampled by the DIG thread; same NHM map) | +| Jaguar3 (8822CU/8822EU) | yes | newer BB — CCA 0x2c08, CCK-FA 0x1a5c, OFDM-FA 0x2d0x, IGI 0x1d70; NHM 0x1e60/0x1e40/0x1e44/0x2d40/0x2d4c | + +## Detecting a tone + +Run `DEVOURER_CW_TONE` on adapter A and `DEVOURER_RX_ENERGY_MS` on adapter B, same +channel. The sensor's `cca_ofdm` leaves its ambient band in one of two directions, +both an unambiguous detection: + +- **spike** — the CCA registers the carrier as busy and the count jumps far above + baseline (measured ~13–380× on the 2T2R 8822CU); +- **collapse** — a strong co-located carrier saturates the AGC, the RX goes deaf, + and the count (and received frames) fall toward zero (the 1T1R 8821AU / 8821CU). + +The `` histogram is the corroborating signal: the tone moves the +distribution's mass into higher power buckets (measured: peak bucket 5→8 on the +8822CU under a co-located CW tone), so `peak` rises and the high-index `hist` +buckets fill where the baseline had zeros. + +Which direction depends on the chip's AGC behaviour and the tone strength relative +to saturation. `tests/rx_energy_probe.sh` runs the two-adapter test (baseline vs +tone) and `tests/rx_energy_check.py` asserts the two are clearly separable. + +A weaker or spread interferer (e.g. `DEVOURER_NB_BW=5` OFDM instead of a bare CW) +stays in the moderate regime where `cca_ofdm` rises without saturating. + +## `DEVOURER_RX_SWEEP` — a coarse spectrum map + +The energy sensor reads one channel at a time; to localise an interferer in +frequency, sweep. With `DEVOURER_RX_SWEEP="1,6,11"` the sensor cycles the listed +channels — the RX loop runs on a worker thread while the main thread retunes +between reads (`SetMonitorChannel`, uniform across all three generations) — and +emits one `ch=N` line per bin. Aggregating those into an +energy-vs-frequency bar chart peaks (or, on the saturating 1T1R parts, dips) at +the tone's channel. + +The resolution is the channel grid: 20 MHz on the 2.4/5 GHz plan, and down to +~5 MHz on Jaguar3 (`DEVOURER_NB_BW=5` — the 2.4 GHz channels are 5 MHz apart, so +stepping them at 5 MHz bandwidth gives 5 MHz bins). This is a scalar-energy +spectrum, not an FFT — there is no sub-channel structure within a bin. + +`tests/rx_spectrum_sweep.sh` runs a single live sweep and `tests/rx_spectrum_sweep.py` +renders the map + flags the peak/dip bin. + +## Per-tone localisation + +Finer than the channel grid needs a different mechanism: the self-sounding +beamforming report (`docs/beamforming-self-sounding.md`), the only per-tone +readout this silicon offers, since the compressed report is computed in the BB +rather than DMA'd as a raw channel matrix. It carries two per-subcarrier +observables a narrowband interferer perturbs on just the tones it covers: + +- **per-tone SNR** (the MU Exclusive report) — an interferer raising the noise + floor on a subcarrier group cuts its SNR: a localized notch; +- **per-tone cross-frame ψ variance** — an interferer corrupting the channel + estimate on those tones makes the compressed steering angle jump frame to + frame: a localized variance spike. + +`tests/rx_tone_localize.py` decodes the reports (reusing `tools/bf_report_decode.py`), +robustly thresholds both observables (median/MAD outliers, or a differential +against a clean baseline), groups the flagged tones, and maps each group to a +frequency — resolution `BW/Ns`, ~385 kHz per subcarrier group on 20 MHz Ng=1, +i.e. sub-channel. `tests/rx_tone_localize.sh` drives the MU self-sounding rig and +an optional CW-tone interferer. The detection + frequency-mapping math is guarded +headlessly by the `rx_tone_localize_math` CTest (`--self-test`). + +Regime caveat (measured): on a flat, short line-of-sight bench with the +interferer co-located inches away, the per-tone structure is quantisation-limited +(the ψ variance floor competes with a real notch) and a CW interferer is bistable +— weak enough to avoid saturating the receiver leaves it buried in that floor, +strong enough to register collapses the whole report path (the AGC-saturation +regime of the energy sensor above). The localiser bites where the interferer is +spatially separated or the channel is frequency-selective (multipath / wider BW), +so the per-tone SNR develops real structure above the quantisation floor. diff --git a/src/IRtlDevice.h b/src/IRtlDevice.h index fe2e81c..e71f509 100644 --- a/src/IRtlDevice.h +++ b/src/IRtlDevice.h @@ -5,6 +5,7 @@ #include #include +#include "RxSense.h" #include "SelectedChannel.h" #include "TxMode.h" @@ -62,6 +63,14 @@ class IRtlDevice { * silently go on-air at 1 Mbps. */ virtual void SetTxMode(const devourer::TxMode & /*mode*/) {} virtual void ClearTxMode() {} + + /* Frame-free RX energy / channel-busy snapshot (see RxSense.h) — the read side + * of the DEVOURER_CW_TONE emitter, used for spectrum-sensing / interferer + * detection. Reads the chip's phydm false-alarm + CCA counters, DIG/IGI, and + * (optionally) the NHM power histogram. FA/CCA counts are the delta since the + * previous call. Default returns an all-invalid snapshot; each generation + * overrides with a real reader. */ + virtual RxEnergy GetRxEnergy() { return {}; } }; #endif /* IRTL_DEVICE_H */ diff --git a/src/NhmReader.h b/src/NhmReader.h new file mode 100644 index 0000000..ea7b865 --- /dev/null +++ b/src/NhmReader.h @@ -0,0 +1,115 @@ +#ifndef DEVOURER_NHM_READER_H +#define DEVOURER_NHM_READER_H + +#include +#include +#include +#include + +#include "RxSense.h" + +/* NHM (Noise Histogram Measurement) — a frame-free, hardware in-band + * power-distribution readout, ported from Realtek's phydm CCX + * (phydm_ccx.c in each reference tree). The BB autonomously bins received signal + * power into 12 IGI-referenced buckets over a measurement window; the driver + * sets 11 thresholds, pulses a trigger, polls a ready bit, and reads back the + * 12 one-byte bucket counters. Bucket 0 = power below threshold[0], bucket i + * (1..10) = th[i-1]..th[i], bucket 11 = above threshold[10]. This is the richer + * companion to the scalar FA/CCA/IGI energy sensor: a coarse spectrum-free power + * histogram whose mass shifts up when an in-band interferer raises the floor. + * + * The algorithm is identical across chip generations; only the register map + * differs (11AC: Jaguar1/2; JGR3: Jaguar3). NhmRegs carries the per-generation + * addresses so a single implementation serves all three. */ +namespace devourer { + +struct NhmRegs { + uint16_t ctrl; /* [1]=trigger, [11:8]=cfg, [31:16]=th9|th10<<8 */ + uint16_t period; /* [31:16]=measurement period (4us units) */ + uint16_t th0_3; /* th[0..3], one byte each */ + uint16_t th4_7; /* th[4..7] */ + uint16_t th8; /* th[8] at th8_shift */ + uint8_t th8_shift; /* 0 (11AC, byte0) / 16 (JGR3, byte2) */ + uint16_t ready; /* [16]=ready, [15:0]=duration */ + uint16_t res0_3; /* bucket[0..3] */ + uint16_t res4_7; /* bucket[4..7] */ + uint16_t res8_11; /* bucket[8..11] */ +}; + +/* 11AC map — Jaguar1 (8812/8814/8821AU) and Jaguar2 (8822BU/8821CU). */ +inline NhmRegs nhm_regs_11ac() { + return NhmRegs{0x994, 0x990, 0x998, 0x99c, 0x9a0, 0, + 0xfb4, 0xfa8, 0xfac, 0xfb0}; +} + +/* JGR3 map — Jaguar3 (8822CU/8822EU). */ +inline NhmRegs nhm_regs_jgr3() { + return NhmRegs{0x1e60, 0x1e40, 0x1e44, 0x1e48, 0x1e5c, 16, + 0x2d4c, 0x2d40, 0x2d44, 0x2d48}; +} + +/* Run one NHM measurement and fill e.nhm[]/nhm_duration/valid_nhm. + * read32(addr) — read a 32-bit BB register + * set_bb(addr,mask,val) — masked BB-register write (phy_set_bb_reg) + * igi7 — current 7-bit IGI (0xC50/0x1d70), the histogram reference + * period — measurement window in 4us units (default 500 = ~2ms) + * The thresholds follow phydm's NHM_BACKGROUND recipe (IGI-relative), so the + * buckets are referenced to the receiver's own noise floor, not absolute dBm. */ +inline void read_nhm(const NhmRegs& r, uint8_t igi7, + const std::function& read32, + const std::function& set_bb, + RxEnergy& e, uint16_t period = 500) { + /* Thresholds (phydm_nhm_th_update_chk, NHM_BACKGROUND): unit PWdB U(8,1). + * th[0] = (igi - CCA_CAP) * 2, th[i] = th[0] + 4*i for i=1..10. */ + constexpr int kCcaCap = 14; + int base = (static_cast(igi7) - kCcaCap) * 2; + if (base < 0) base = 0; + uint8_t th[11]; + for (int i = 0; i < 11; i++) { + int v = base + 4 * i; + th[i] = v > 255 ? 255 : static_cast(v); + } + + /* Config [11:8] = (divi<<3)|(inc_tx<<2)|(inc_cca<<1)|ccx_en. + * ccx_en=1, include_cca=1 (count busy so a CW interferer registers), + * include_tx=0, divider=NHM_CNT_ALL(0) -> 0b0011. */ + set_bb(r.ctrl, 0xf00u, 0x3u); + set_bb(r.period, 0xffff0000u, static_cast(period)); + + set_bb(r.th0_3, 0xffffffffu, + th[0] | (th[1] << 8) | (th[2] << 16) | (uint32_t(th[3]) << 24)); + set_bb(r.th4_7, 0xffffffffu, + th[4] | (th[5] << 8) | (th[6] << 16) | (uint32_t(th[7]) << 24)); + set_bb(r.th8, 0xffu << r.th8_shift, uint32_t(th[8]) << r.th8_shift); + set_bb(r.ctrl, 0xffff0000u, (th[9] | (uint32_t(th[10]) << 8)) << 16); + + /* Trigger (pulse bit1 0->1). */ + set_bb(r.ctrl, 0x2u, 0); + set_bb(r.ctrl, 0x2u, 1); + + /* Poll ready (bit16). Window ~period*4us; cap the wait so a stuck read never + * stalls the caller (holds a register lock on Jaguar3). */ + bool ready = false; + for (int i = 0; i < 15 && !ready; i++) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + if (read32(r.ready) & (1u << 16)) + ready = true; + } + if (!ready) { + e.valid_nhm = false; + return; + } + + const uint32_t a = read32(r.res0_3), b = read32(r.res4_7), + c = read32(r.res8_11); + const uint32_t words[3] = {a, b, c}; + for (int w = 0; w < 3; w++) + for (int k = 0; k < 4; k++) + e.nhm[w * 4 + k] = (words[w] >> (8 * k)) & 0xff; + e.nhm_duration = static_cast(read32(r.ready) & 0xffff); + e.valid_nhm = true; +} + +} // namespace devourer + +#endif /* DEVOURER_NHM_READER_H */ diff --git a/src/RxSense.h b/src/RxSense.h new file mode 100644 index 0000000..32bb11f --- /dev/null +++ b/src/RxSense.h @@ -0,0 +1,45 @@ +#ifndef RX_SENSE_H +#define RX_SENSE_H + +#include + +/* RxEnergy — a frame-free RX energy / channel-busy snapshot. This is the read + * side of the DEVOURER_CW_TONE emitter: a coarse "how much in-band energy / + * channel activity is here" measurement that does NOT require receiving a frame. + * + * Filled by IRtlDevice::GetRxEnergy() from the chip's phydm facilities: + * - false-alarm (FA) + CCA (channel-busy) counters, + * - the DIG initial-gain index (a noise-floor proxy), + * - and, where triggered, the NHM in-band power histogram. + * + * All values are channel-wide scalars — no Realtek 88xx chip exports + * per-subcarrier CSI to the host, so this is energy, not a spectrum. Build a + * coarse spectrum by sweeping channels/bins and sampling this per bin. + * + * FA/CCA counts are the DELTA since the previous GetRxEnergy() call (each read + * resets the hardware counters), so a strong in-band carrier shows up as a jump + * in cca_ofdm / fa_ofdm and a rise in igi. Every field carries a valid_* flag + * because the facilities differ by chip generation. */ +struct RxEnergy { + /* phydm false-alarm + CCA counters (delta since the previous read). */ + bool valid_fa = false; + uint32_t fa_ofdm = 0; /* OFDM false-alarm count */ + uint32_t fa_cck = 0; /* CCK false-alarm count */ + uint32_t cca_ofdm = 0; /* OFDM CCA (channel-busy) count */ + uint32_t cca_cck = 0; /* CCK CCA count */ + + /* DIG initial-gain index (0x0c50[6:0] on the AC BB): the AGC backs the gain + * off as the in-band floor rises, so a higher IGI means a busier/noisier + * channel. Noise-floor proxy. */ + bool valid_igi = false; + uint8_t igi = 0; + + /* NHM in-band power histogram: 12 IGI-referenced power buckets and the + * measurement duration. A frame-free power distribution (fuller than the + * scalar FA counts). Only populated when GetRxEnergy() triggered an NHM. */ + bool valid_nhm = false; + uint8_t nhm[12] = {}; + uint16_t nhm_duration = 0; +}; + +#endif /* RX_SENSE_H */ diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index 09dbe20..edb5862 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -2,6 +2,7 @@ #include "BeamformingSounder.h" #include "EepromManager.h" #include "Hal8812PhyReg.h" +#include "NhmReader.h" #include "RadioManagementModule.h" #include "SignalStop.h" #include "ToneMask.h" @@ -187,6 +188,44 @@ void RtlJaguarDevice::StopCwTone() { _logger->info("CW single-tone stopped — chip restored"); } +/* Frame-free RX energy snapshot for the AC (Jaguar-1) BB. Reads the phydm + * OFDM/CCK false-alarm (0xF48/0xA5C) + CCA (0xF08) counters and the DIG IGI + * (0xC50), then resets the counters (phydm_false_alarm_counter_reg_reset AC: + * 0x9a4[17], 0xa2c[15], 0xb58[0]) so the next call sees only the delta. Same + * register set PhydmWatchdog::ReadFaCountersAc uses. */ +RxEnergy RtlJaguarDevice::GetRxEnergy() { + RxEnergy e; + auto bb = [this](uint16_t addr) { + return _radioManagement->phy_query_bb_reg_public(addr, 0xFFFFFFFF); + }; + e.fa_ofdm = bb(0x0F48) & 0xFFFF; + e.fa_cck = bb(0x0A5C) & 0xFFFF; + const uint32_t cca = bb(0x0F08); + e.cca_ofdm = (cca >> 16) & 0xFFFF; + e.cca_cck = cca & 0xFFFF; + e.valid_fa = true; + e.igi = static_cast(_device.rtw_read8(0x0C50) & 0x7F); + e.valid_igi = true; + + /* Reset the FA/CCA counter latches (read-then-reset = per-call delta). */ + _device.phy_set_bb_reg(0x09A4, 1u << 17, 1); + _device.phy_set_bb_reg(0x09A4, 1u << 17, 0); + _device.phy_set_bb_reg(0x0A2C, 1u << 15, 0); + _device.phy_set_bb_reg(0x0A2C, 1u << 15, 1); + _device.phy_set_bb_reg(0x0B58, 1u << 0, 1); + _device.phy_set_bb_reg(0x0B58, 1u << 0, 0); + + /* NHM 12-bucket power histogram (frame-free, 11AC register map). */ + devourer::read_nhm( + devourer::nhm_regs_11ac(), e.igi, + [this](uint16_t a) { return _device.rtw_read(a); }, + [this](uint16_t a, uint32_t m, uint32_t v) { + _device.phy_set_bb_reg(a, m, v); + }, + e); + return e; +} + /* Map a radiotap CHANNEL frequency (MHz) to a Wi-Fi channel number. Returns 0 * for a frequency outside the bands devourer drives (caller ignores it). */ static int freq_to_chan(uint16_t freq_mhz) { diff --git a/src/jaguar1/RtlJaguarDevice.h b/src/jaguar1/RtlJaguarDevice.h index 3697bc7..44c4a96 100644 --- a/src/jaguar1/RtlJaguarDevice.h +++ b/src/jaguar1/RtlJaguarDevice.h @@ -102,6 +102,13 @@ class RtlJaguarDevice : public IRtlDevice { void StartCwTone(uint8_t gain); void StopCwTone(); + /* Frame-free RX energy / channel-busy snapshot (see RxSense.h) — reads the + * phydm OFDM/CCK false-alarm + CCA counters (0xF48/0xA5C/0xF08) and the DIG + * IGI noise-floor (0xC50), then resets the counters so the next call is a + * fresh delta. The read side of the CW tone. NB: if DEVOURER_PHYDM_WATCHDOG is + * also running it shares/steals these counters. */ + RxEnergy GetRxEnergy() override; + /* Runtime TX-mode default. send_packet honours a frame's own radiotap rate * fields per-packet; when a frame's radiotap carries no rate, this mode * supplies the modulation / MCS / BW / GI / FEC / STBC instead of the diff --git a/src/jaguar2/HalJaguar2.cpp b/src/jaguar2/HalJaguar2.cpp index 1c44227..d15aa06 100644 --- a/src/jaguar2/HalJaguar2.cpp +++ b/src/jaguar2/HalJaguar2.cpp @@ -1303,6 +1303,17 @@ void HalJaguar2::dig_step() { uint32_t fa = ofdm_fa + cck_fa; _last_fa = fa; + /* Frame-free energy snapshot for GetRxEnergy() — the CCA (channel-busy) count + * (0x0f08[31:16]=OFDM, [15:0]=CCK) is the primary in-band-energy signal (a CW + * tone spikes OFDM CCA), captured over the same window as the FA counts just + * before the reset. IGI is stored below. */ + uint32_t cca = _device.rtw_read32(0x0f08); + _energy.fa_ofdm = ofdm_fa; + _energy.fa_cck = cck_fa; + _energy.cca_ofdm = (cca >> 16) & 0xffff; + _energy.cca_cck = cca & 0xffff; + _energy.valid_fa = true; + /* Reset the hold-type FA/CCA counters so the next window is a fresh delta * (11AC phydm_reset_bb_hw_cnt path): OFDM-FA 0x9a4[17] (1->0 = reset->enable), * CCK-FA 0xa2c[15] (0->1), CCA 0xb58[0] (1->0). */ @@ -1314,6 +1325,8 @@ void HalJaguar2::dig_step() { _device.phy_set_bb_reg(0x0b58, 1u << 0, 0); uint8_t igi = static_cast(_device.rtw_read8(0x0c50) & 0x7f); + _energy.igi = igi; + _energy.valid_igi = true; uint8_t ni = igi; if (fa > 750) ni = static_cast(igi + 2); diff --git a/src/jaguar2/HalJaguar2.h b/src/jaguar2/HalJaguar2.h index f1a12c2..c3bcbcd 100644 --- a/src/jaguar2/HalJaguar2.h +++ b/src/jaguar2/HalJaguar2.h @@ -6,6 +6,7 @@ #include "logger.h" #include "RtlUsbAdapter.h" +#include "RxSense.h" #include "ChipVariant.h" #include "Jaguar2PhyTables.h" @@ -125,6 +126,11 @@ class HalJaguar2 { void dig_step(); uint8_t dbg_igi() { return static_cast(_device.rtw_read8(0x0c50) & 0x7f); } uint32_t dbg_last_fa() const { return _last_fa; } + /* Frame-free RX energy snapshot from the last dig_step window (FA/CCA/IGI). + * dig_step already reads+resets these counters on its ~100 ms cadence, so the + * energy poller piggybacks it rather than racing the reset. Invalid until + * dig_step has run at least once (i.e. requires the DIG thread — default on). */ + RxEnergy last_energy() const { return _energy; } /* Debug: direct RF register read (direct-BB window). For RX bring-up probes. */ uint32_t dbg_rf_read(uint8_t path, uint32_t addr) { return rf_read(path, addr); } @@ -182,6 +188,7 @@ class HalJaguar2 { ChipVersion _ver{}; bool _aac_checked = false; uint32_t _last_fa = 0; /* last DIG-window false-alarm count (telemetry) */ + RxEnergy _energy{}; /* last DIG-window FA/CCA/IGI snapshot (telemetry) */ /* Decoded logical EFUSE map, read once (a full physical walk is ~hundreds of * USB control transfers ≈ 0.5s). read_efuse_rfe populates it; apply_tx_power * reuses it — avoids a second walk that would slow every cold init. */ diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index 4df73b8..b73c683 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -14,6 +14,7 @@ #include "BeamformingSounder.h" #include "FrameParserJaguar2.h" #include "Jaguar2Calibration.h" +#include "NhmReader.h" #include "ToneMask.h" #include "RateDefinitions.h" #include "RxPacket.h" @@ -81,6 +82,7 @@ void RtlJaguar2Device::bring_up(SelectedChannel channel) { * the TX front-end mis-routed. DEVOURER_RFE=N overrides. */ if (const char *e = getenv("DEVOURER_RFE")) rfe = static_cast(strtol(e, nullptr, 0)); + _rfe = rfe; /* cache for SetMonitorChannel retune */ _hal.apply_bb_rf_agc_tables(rfe); _logger->info("RtlJaguar2Device: PHY tables applied"); @@ -444,6 +446,31 @@ void RtlJaguar2Device::StopCwTone() { void RtlJaguar2Device::SetMonitorChannel(SelectedChannel channel) { _channel = channel; + /* Retune the RF/BB to the new channel. set_channel_bw is a pure tune (RF18 + + * bandwidth registers) — no per-channel LCK/IQK/TX-power — so it is cheap + * enough to drive a live spectrum sweep. The stale IQK from bring-up slightly + * degrades RX quality on the new channel but does not affect the frame-free + * CCA/FA energy counters the sweep reads. */ + _hal.set_channel_bw(static_cast(channel.Channel), + static_cast(channel.ChannelWidth), _rfe, + channel.ChannelOffset); +} + +RxEnergy RtlJaguar2Device::GetRxEnergy() { + /* Scalar FA/CCA/IGI come from the DIG thread's cached snapshot (no USB); + * append a fresh NHM power histogram (11AC register map). NHM's registers + * (0x994/0x990/0x998.. + 0xfa8/0xfb4) don't overlap the DIG thread's + * (0xc50 IGI + FA counters), so the concurrent read is race-free enough for + * this diagnostic. */ + RxEnergy e = _hal.last_energy(); + devourer::read_nhm( + devourer::nhm_regs_11ac(), e.igi, + [this](uint16_t a) { return _device.rtw_read(a); }, + [this](uint16_t a, uint32_t m, uint32_t v) { + _device.phy_set_bb_reg(a, m, v); + }, + e); + return e; } void RtlJaguar2Device::SetTxPower(uint8_t power) { _tx_pwr_override = power; } diff --git a/src/jaguar2/RtlJaguar2Device.h b/src/jaguar2/RtlJaguar2Device.h index b8b66ae..1528285 100644 --- a/src/jaguar2/RtlJaguar2Device.h +++ b/src/jaguar2/RtlJaguar2Device.h @@ -66,6 +66,11 @@ class RtlJaguar2Device : public IRtlDevice { void StartCwTone(uint8_t gain); void StopCwTone(); + /* Frame-free RX energy snapshot (see RxSense.h) — the FA/CCA/IGI values + * dig_step samples over its ~100 ms window, plus a fresh NHM power histogram. + * The read side of the CW tone. */ + RxEnergy GetRxEnergy() override; + private: RtlUsbAdapter _device; Logger_t _logger; @@ -76,6 +81,9 @@ class RtlJaguar2Device : public IRtlDevice { SelectedChannel _channel{}; Action_ParsedRadioPacket _packetProcessor = nullptr; int _tx_pwr_override = -1; + /* rfe_type resolved during bring_up (efuse + DEVOURER_RFE), cached so + * SetMonitorChannel can retune (set_channel_bw needs it). */ + uint8_t _rfe = 0; std::optional _tx_mode_default; /* CW single-tone (StartCwTone/StopCwTone) saved state for a clean restore: diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index f62f3c1..a4119e9 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -9,6 +9,7 @@ #include "BeamformingSounder.h" /* generation-neutral BF self-sounding recipe */ #include "FrameParserJaguar3.h" +#include "NhmReader.h" /* frame-free NHM power histogram (shared) */ #include "RateDefinitions.h" /* MGN_* rate enum (shared across the family) */ #include "SignalStop.h" /* g_devourer_should_stop — set by demo signal handlers */ #include "ToneMask.h" /* DEVOURER_RX_CSI_MASK / DEVOURER_RX_NBI knobs */ @@ -520,6 +521,59 @@ void RtlJaguar3Device::StopCwTone() { _logger->info("CW single-tone stopped — chip restored"); } +/* Frame-free RX energy snapshot for the Jaguar3 (8822C/E) BB. Ported from + * phydm_fa_cnt_statistics_jgr3 + phydm_reset_bb_hw_cnt (reference/rtl88x2cu + * phydm_dig.c / phydm_api.c): the newer BB holds the same FA/CCA facilities as + * the AC classic map, just at different addresses. CCA (0x2c08) is the primary + * channel-busy signal (a CW tone spikes OFDM CCA); OFDM FA is the vendor sum of + * the sub-counters. Read-then-reset for a per-call delta; serialized on _reg_mu + * so it does not race the coex thread's register access. */ +RxEnergy RtlJaguar3Device::GetRxEnergy() { + std::lock_guard lk(_reg_mu); + RxEnergy e; + auto rd = [this](uint16_t addr) { return _device.rtw_read(addr); }; + + const uint32_t cca = rd(0x2c08); + e.cca_ofdm = (cca >> 16) & 0xffff; + e.cca_cck = cca & 0xffff; + e.fa_cck = rd(0x1a5c) & 0xffff; + /* OFDM FA = parity + rate-illegal + crc8 + mcs + fast-fsync + sb-search + + * mcs-vht + crc8-vhta (phydm_fa_cnt_statistics_jgr3). */ + const uint32_t r2d04 = rd(0x2d04), r2d08 = rd(0x2d08), r2d10 = rd(0x2d10), + r2d20 = rd(0x2d20), r2d0c = rd(0x2d0c); + e.fa_ofdm = ((r2d04 >> 16) & 0xffff) + (r2d08 & 0xffff) + + ((r2d08 >> 16) & 0xffff) + (r2d10 & 0xffff) + (r2d20 & 0xffff) + + ((r2d20 >> 16) & 0xffff) + ((r2d10 >> 16) & 0xffff) + + (r2d0c & 0xffff); + e.valid_fa = true; + e.igi = static_cast(rd(0x1d70) & 0x7f); + e.valid_igi = true; + + /* NHM 12-bucket power histogram (frame-free, JGR3 register map). Runs its own + * ~2 ms measurement window before the FA-counter reset below (0x1eb4[25] also + * clears BB HW counters). Holds _reg_mu across the short wait — tolerable at + * the emitter's >=100 ms cadence vs the coex thread's ~2 s tick. */ + devourer::read_nhm( + devourer::nhm_regs_jgr3(), e.igi, rd, + [this](uint16_t a, uint32_t m, uint32_t v) { + _device.phy_set_bb_reg(a, m, v); + }, + e); + + /* Reset: CCK FA 0x1a2c[15:14] 0->2, CCK CCA 0x1a2c[13:12] 0->2, then OFDM + * CCA/FA (phydm_reset_bb_hw_cnt jgr3: 0x1eb4[25] 1->0, wrapped by the + * 0x1d2c[31] rx-clk-gate disable/enable). */ + _device.phy_set_bb_reg(0x1a2c, 0x3u << 14, 0x0); + _device.phy_set_bb_reg(0x1a2c, 0x3u << 14, 0x2); + _device.phy_set_bb_reg(0x1a2c, 0x3u << 12, 0x0); + _device.phy_set_bb_reg(0x1a2c, 0x3u << 12, 0x2); + _device.phy_set_bb_reg(0x1d2c, 1u << 31, 0x0); + _device.phy_set_bb_reg(0x1eb4, 1u << 25, 0x1); + _device.phy_set_bb_reg(0x1eb4, 1u << 25, 0x0); + _device.phy_set_bb_reg(0x1d2c, 1u << 31, 0x1); + return e; +} + void RtlJaguar3Device::SetMonitorChannel(SelectedChannel channel) { _channel = channel; _radioManagement.set_channel_bwmode(channel.Channel, channel.ChannelOffset, diff --git a/src/jaguar3/RtlJaguar3Device.h b/src/jaguar3/RtlJaguar3Device.h index 530efa3..4e637e0 100644 --- a/src/jaguar3/RtlJaguar3Device.h +++ b/src/jaguar3/RtlJaguar3Device.h @@ -70,6 +70,14 @@ class RtlJaguar3Device : public IRtlDevice { void StartCwTone(uint8_t gain); void StopCwTone(); + /* Frame-free RX energy / channel-busy snapshot (see RxSense.h). Jaguar3's + * phydm DIG/FA machinery was never ported to devourer, but the 8822C/E BB has + * the same facilities in a newer register space — OFDM/CCK CCA (0x2c08), CCK + * FA (0x1a5c), OFDM FA (sum of 0x2d04/08/10/20/0c), IGI (0x1d70) — reset via + * 0x1a2c + 0x1eb4[25]. Read-then-reset for a per-call delta; serialized on + * _reg_mu against the coex runtime thread. The read side of the CW tone. */ + RxEnergy GetRxEnergy() override; + bool should_stop = false; private: diff --git a/tests/rx_energy_check.py b/tests/rx_energy_check.py new file mode 100755 index 0000000..915e24c --- /dev/null +++ b/tests/rx_energy_check.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Verdict for tests/rx_energy_probe.sh: is a CW interferer detectable in the +frame-free RX energy telemetry? + +Parses the sensor's lines from a baseline (tone off) capture +and a tone-on capture, and asserts the two are clearly separable in the CCA +counter. A strong co-located CW pushes the sensor's cca_ofdm out of its ambient +band in one of two ways, both a valid detection: + + - SPIKE: cca_ofdm rises far above baseline (the CCA registers the carrier as + busy). Seen on the 2T2R 8822CU (~13x). + - COLLAPSE: cca_ofdm falls toward zero because the carrier saturates the AGC + and the RX goes deaf. Seen on the 1T1R 8821AU / 8821C. + +Pass if the tone-on median differs from the baseline median by >= 3x (either +direction), or the tone-on median collapses below a small floor while baseline +was clearly active. + + python3 rx_energy_check.py baseline.log tone.log +""" +from __future__ import annotations +import re +import sys +import statistics + +_CCA = re.compile(r"cca_ofdm=(\d+)") + + +def cca_series(path: str) -> list[int]: + out = [] + try: + with open(path) as f: + for ln in f: + m = _CCA.search(ln) + if m: + out.append(int(m.group(1))) + except FileNotFoundError: + pass + # drop the first sample (often the pre-bring-up 0) + return out[1:] if len(out) > 1 else out + + +def main() -> int: + if len(sys.argv) != 3: + sys.stderr.write("usage: rx_energy_check.py baseline.log tone.log\n") + return 2 + base = cca_series(sys.argv[1]) + tone = cca_series(sys.argv[2]) + if not base or not tone: + print(f"FAIL: not enough samples (baseline={len(base)}, tone={len(tone)})") + return 1 + + b = statistics.median(base) + t = statistics.median(tone) + print(f"baseline cca_ofdm median={b:.0f} (n={len(base)}, " + f"min={min(base)} max={max(base)})") + print(f"tone cca_ofdm median={t:.0f} (n={len(tone)}, " + f"min={min(tone)} max={max(tone)})") + + # SPIKE: tone median >= 3x baseline (the CCA registers the carrier as busy). + # COLLAPSE: baseline was clearly active and the tone drives the CCA at least + # 2.5x lower with its whole distribution below the baseline median (AGC + # saturated / RX deaf). Both directions are a valid detection. + spike = b > 0 and t >= 3 * b + collapse = b >= 30 and t * 2.5 <= b and max(tone) < b + if spike: + print(f"PASS: tone SPIKES cca_ofdm {t/max(b,1):.1f}x above baseline " + f"— interferer detected") + return 0 + if collapse: + print(f"PASS: tone COLLAPSES cca_ofdm {b/max(t,1):.1f}x (AGC saturation " + f"/ RX deaf) — interferer detected") + return 0 + print("FAIL: tone-on and baseline cca_ofdm not clearly separable " + "(tone too weak, wrong channel, or not armed?)") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/rx_energy_probe.sh b/tests/rx_energy_probe.sh new file mode 100755 index 0000000..c1dc089 --- /dev/null +++ b/tests/rx_energy_probe.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# RX energy / interferer-detection validation — the read side of DEVOURER_CW_TONE. +# +# Runs a two-adapter, no-SDR test: one adapter emits a CW tone (DEVOURER_CW_TONE) +# on a channel; a second adapter runs the RX demo with DEVOURER_RX_ENERGY_MS and +# reports the frame-free energy telemetry (). We capture the +# sensor's telemetry with the tone OFF (baseline) and ON, then assert the two are +# clearly separable (rx_energy_check.py). A strong co-located CW pushes the +# sensor's CCA counter far out of its ambient band — either a large spike (the +# CCA registers the carrier as busy) or a collapse toward zero (the carrier +# saturates the AGC and the RX goes deaf). Both are unambiguous detections. +# +# Requires two USB Wi-Fi adapters plugged in and sudo (USB claim). +# +# sudo ./tests/rx_energy_probe.sh --sensor-pid 0xc812 \ +# --tone-pid 0x8812 --channel 6 +# sudo ./tests/rx_energy_probe.sh --sensor-vid 0x2357 --sensor-pid 0x0120 ... +# +# Cleanup: exact-comm kills of both demos on any exit. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +DEMO_RX="$ROOT/build/WiFiDriverDemo" +DEMO_TX="$ROOT/build/WiFiDriverTxDemo" + +SENSOR_VID=0x0bda SENSOR_PID="" +TONE_VID=0x0bda TONE_PID="" +CHANNEL=6 GAIN=8 ENERGY_MS=800 SECS=8 OUT=/tmp/devourer-rx-energy + +while [ $# -gt 0 ]; do + case "$1" in + --sensor-vid) SENSOR_VID="$2"; shift 2 ;; + --sensor-pid) SENSOR_PID="$2"; shift 2 ;; + --tone-vid) TONE_VID="$2"; shift 2 ;; + --tone-pid) TONE_PID="$2"; shift 2 ;; + --channel) CHANNEL="$2"; shift 2 ;; + --gain) GAIN="$2"; shift 2 ;; + --energy-ms) ENERGY_MS="$2"; shift 2 ;; + --secs) SECS="$2"; shift 2 ;; + --outdir) OUT="$2"; shift 2 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done +[ -n "$SENSOR_PID" ] && [ -n "$TONE_PID" ] || { + echo "need --sensor-pid and --tone-pid" >&2; exit 2; } + +cleanup() { for c in WiFiDriverDemo WiFiDriverTxDemo; do pkill -x "$c" 2>/dev/null || true; done; } +trap cleanup EXIT INT TERM + +echo "== building ==" +cmake --build "$ROOT/build" -j >/dev/null +mkdir -p "$OUT" +cleanup; sleep 2 + +sense() { # $1=logfile + timeout -sINT "$((SECS + 3))" env DEVOURER_VID="$SENSOR_VID" \ + DEVOURER_PID="$SENSOR_PID" DEVOURER_CHANNEL="$CHANNEL" \ + DEVOURER_RX_ENERGY_MS="$ENERGY_MS" "$DEMO_RX" 2>/dev/null \ + | grep --line-buffered "devourer-energy" > "$1" || true +} + +echo "== baseline (tone OFF) ==" +sense "$OUT/baseline.log" +sleep 2 + +echo "== tone ON ($TONE_PID ch$CHANNEL gain $GAIN) ==" +timeout -sINT "$((SECS + 20))" env DEVOURER_VID="$TONE_VID" \ + DEVOURER_PID="$TONE_PID" DEVOURER_CHANNEL="$CHANNEL" DEVOURER_CW_TONE=1 \ + DEVOURER_CW_TONE_GAIN="$GAIN" "$DEMO_TX" >"$OUT/tone.emit.log" 2>&1 & +TX=$! +sleep 8 +grep -q "single-tone armed" "$OUT/tone.emit.log" && echo " tone armed" \ + || echo " WARN: tone not armed (see $OUT/tone.emit.log)" +sense "$OUT/tone.log" +kill -INT "$TX" 2>/dev/null || true; wait "$TX" 2>/dev/null || true + +echo "== verdict ==" +python3 "$HERE/rx_energy_check.py" "$OUT/baseline.log" "$OUT/tone.log" diff --git a/tests/rx_spectrum_sweep.py b/tests/rx_spectrum_sweep.py new file mode 100755 index 0000000..185f13f --- /dev/null +++ b/tests/rx_spectrum_sweep.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Render a coarse RX spectrum map from a live DEVOURER_RX_SWEEP log. + +Parses the `ch=N ...` lines the sensor emits (one per dwell), +aggregates the frame-free CCA-OFDM energy per channel across sweep rounds, and +draws an ASCII energy-vs-frequency bar chart. Flags the peak bin (a spike +interferer) and, when the floor is clearly non-zero, the deepest dip (a 1T1R +part whose AGC saturates and goes deaf under a strong co-located carrier). + +No per-tone CSI is available on this silicon (see docs/rx-spectrum-sensing.md); +the resolution is the channel grid — 20 MHz on the 2.4/5 GHz plan, ~5 MHz on +Jaguar3 with --nb-bw 5. +""" +import argparse +import re +import statistics +import sys + +LINE = re.compile(r"(.*)") +KV = re.compile(r"(\w+)=(\S+)") + + +def parse(path): + """channel -> list of cca_ofdm samples (ints), in file order.""" + per_ch = {} + with open(path) as f: + for raw in f: + m = LINE.search(raw) + if not m: + continue + kv = dict(KV.findall(m.group(1))) + if "ch" not in kv: + continue + try: + ch = int(kv["ch"]) + except ValueError: + continue + v = kv.get("cca_ofdm", "-") + if v == "-": + continue + try: + per_ch.setdefault(ch, []).append(int(v)) + except ValueError: + pass + return per_ch + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--log", required=True) + ap.add_argument("--nb-bw", default=None, help="bin bandwidth (MHz), for the header only") + args = ap.parse_args() + + per_ch = parse(args.log) + if not per_ch: + print("no ch= samples in log", file=sys.stderr) + return 1 + + chans = sorted(per_ch) + # Drop the first sample per channel (first dwell after a retune can catch the + # AGC still settling); keep the rest, median for robustness. + energy = {} + for ch in chans: + s = per_ch[ch] + s = s[1:] if len(s) > 1 else s + energy[ch] = statistics.median(s) + + peak_ch = max(energy, key=energy.__getitem__) + peak = energy[peak_ch] + floor = statistics.median(list(energy.values())) + scale = peak if peak > 0 else 1 + width = 50 + + bw = f"{args.nb_bw} MHz" if args.nb_bw else "channel-grid" + print(f"RX spectrum map ({bw} bins, cca_ofdm median over rounds)\n") + for ch in chans: + e = energy[ch] + bar = "#" * int(round(width * e / scale)) + mark = "" + if ch == peak_ch and peak > 1.5 * max(floor, 1): + mark = " <-- PEAK (interferer)" + print(f"ch {ch:>3} | {bar:<{width}} {int(e):>8}{mark}") + + # A dip is only meaningful against a clearly non-zero floor (the collapse + # signature — a saturated 1T1R receiver). + dip_ch = min(energy, key=energy.__getitem__) + if floor >= 30 and energy[dip_ch] * 2.5 <= floor: + print(f"\ndip: ch {dip_ch} (energy {int(energy[dip_ch])} vs floor " + f"{int(floor)}) — possible AGC-saturation collapse (1T1R)") + print(f"\npeak ch {peak_ch} floor {int(floor)} peak {int(peak)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/rx_spectrum_sweep.sh b/tests/rx_spectrum_sweep.sh new file mode 100755 index 0000000..c215ca2 --- /dev/null +++ b/tests/rx_spectrum_sweep.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Coarse RX spectrum map — a single live sweep. One sensor process cycles the +# listed channels (DEVOURER_RX_SWEEP), dwelling DEVOURER_RX_SWEEP_DWELL_MS on +# each and emitting one ch=N line per bin from the frame-free +# energy sensor. The devourer-native spectrum analyzer: no per-tone CSI (the +# silicon can't export it), just channel-wide energy per bin. +# +# The RX loop runs on a worker thread while the main thread retunes +# (SetMonitorChannel) between reads — uniform across all three generations +# (Jaguar2's SetMonitorChannel now retunes), so it's one process, not a relaunch +# per channel. Resolution = the channel grid: 20 MHz on the 2.4/5 GHz plan; +# ~5 MHz on Jaguar3 with --nb-bw 5 (the 2.4 GHz channels are 5 MHz apart, so +# stepping them at 5 MHz bandwidth gives 5 MHz bins). +# +# sudo ./tests/rx_spectrum_sweep.sh --sensor-pid 0xc812 --channels 1,3,5,6,7,9,11 +# sudo ./tests/rx_spectrum_sweep.sh --sensor-pid 0xc812 --nb-bw 5 \ +# --channels 1,2,3,4,5,6,7,8,9,10,11,12,13 +# +# Park a DEVOURER_CW_TONE on one channel (another adapter) first; the map should +# peak (or, on the 1T1R parts that saturate, dip) at that channel. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +DEMO_RX="$ROOT/build/WiFiDriverDemo" +DEMO_TX="$ROOT/build/WiFiDriverTxDemo" + +SENSOR_VID=0x0bda SENSOR_PID="" CHANNELS="1,6,11" DWELL_MS=300 ROUNDS=3 +NB_BW="" OUT=/tmp/devourer-rx-sweep +# Optional second adapter parking a CW tone (self-validation): the map should +# peak (or, on a saturating 1T1R sensor, dip) at --tone-channel. +TONE_VID=0x0bda TONE_PID="" TONE_CHANNEL=6 TONE_GAIN=8 + +while [ $# -gt 0 ]; do + case "$1" in + --sensor-vid) SENSOR_VID="$2"; shift 2 ;; + --sensor-pid) SENSOR_PID="$2"; shift 2 ;; + --channels) CHANNELS="$2"; shift 2 ;; + --dwell-ms) DWELL_MS="$2"; shift 2 ;; + --rounds) ROUNDS="$2"; shift 2 ;; + --nb-bw) NB_BW="$2"; shift 2 ;; + --outdir) OUT="$2"; shift 2 ;; + --tone-vid) TONE_VID="$2"; shift 2 ;; + --tone-pid) TONE_PID="$2"; shift 2 ;; + --tone-channel) TONE_CHANNEL="$2"; shift 2 ;; + --tone-gain) TONE_GAIN="$2"; shift 2 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done +[ -n "$SENSOR_PID" ] || { echo "need --sensor-pid" >&2; exit 2; } + +cleanup() { for c in WiFiDriverDemo WiFiDriverTxDemo; do pkill -x "$c" 2>/dev/null || true; done; } +trap cleanup EXIT INT TERM + +echo "== building ==" +cmake --build "$ROOT/build" -j >/dev/null +mkdir -p "$OUT"; rm -f "$OUT"/sweep.log +cleanup; sleep 1 + +if [ -n "$TONE_PID" ]; then + echo "== parking CW tone: $TONE_PID ch$TONE_CHANNEL gain $TONE_GAIN ==" + timeout -sINT 600 env DEVOURER_VID="$TONE_VID" DEVOURER_PID="$TONE_PID" \ + DEVOURER_CHANNEL="$TONE_CHANNEL" DEVOURER_CW_TONE=1 \ + DEVOURER_CW_TONE_GAIN="$TONE_GAIN" "$DEMO_TX" >"$OUT/tone.emit.log" 2>&1 & + sleep 8 + grep -q "single-tone armed" "$OUT/tone.emit.log" && echo " tone armed" \ + || echo " WARN: tone not armed (see $OUT/tone.emit.log)" +fi + +# nbins * rounds bins, ~DWELL_MS each, + ~4 s bring-up, + margin. +nbins="$(awk -F',' '{print NF}' <<< "$CHANNELS")" +secs="$(( (nbins * ROUNDS * DWELL_MS) / 1000 + 8 ))" + +echo "== live sweep: $CHANNELS x $ROUNDS rounds, ${DWELL_MS}ms dwell (~${secs}s) ==" +timeout -sINT "$secs" env DEVOURER_VID="$SENSOR_VID" \ + DEVOURER_PID="$SENSOR_PID" DEVOURER_RX_SWEEP="$CHANNELS" \ + DEVOURER_RX_SWEEP_DWELL_MS="$DWELL_MS" ${NB_BW:+DEVOURER_NB_BW="$NB_BW"} \ + "$DEMO_RX" 2>/dev/null | grep --line-buffered "devourer-energy" \ + | tee "$OUT/sweep.log" || true +cleanup + +echo "== spectrum map ==" +python3 "$HERE/rx_spectrum_sweep.py" --log "$OUT/sweep.log" \ + ${NB_BW:+--nb-bw "$NB_BW"} diff --git a/tests/rx_tone_localize.py b/tests/rx_tone_localize.py new file mode 100755 index 0000000..6adfdf2 --- /dev/null +++ b/tests/rx_tone_localize.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Per-tone interference localizer from VHT beamforming reports. + +The finest frequency-resolution sensor devourer has: where the channel-wide +energy sweep (DEVOURER_RX_SWEEP) resolves to the 20 MHz channel grid, the +self-sounding beamforming report carries a *per-subcarrier* picture — an MU +report's per-tone SNR curve and the per-tone cross-frame psi variance. A +narrowband interferer sitting on a few subcarriers shows up as a localized +notch in the per-tone SNR and/or a spike in per-tone psi variance, which this +script thresholds (robust median/MAD) to flag and locate the interferer to a +subcarrier group (~312 kHz * Ng) — far finer than a channel. + +Input: `HEX` lines (WiFiDriverDemo DEVOURER_BF_DETECT_ +REPORT=4) on stdin or a file. Reuses tools/bf_report_decode.py's analyzer. + +Two modes: + single (default) — outlier detection within one capture: flag tones whose + SNR is depressed (or psi-var elevated) far from the band's own + median. Robust to an interferer covering a minority of tones. + --baseline FILE — differential: compare a clean baseline capture against the + interferer capture and flag tones whose SNR dropped / psi-var rose. + More sensitive; needs a toggled interferer. + +Usage: + WiFiDriverDemo ... DEVOURER_BF_DETECT_REPORT=4 | tests/rx_tone_localize.py \ + --channel 100 --bw 20 + tests/rx_tone_localize.py interferer.txt --baseline clean.txt --channel 100 +""" +from __future__ import annotations + +import argparse +import os +import statistics +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "tools")) +import bf_report_decode as bf # noqa: E402 + + +def load(path_or_none, max_frames): + src = open(path_or_none) if path_or_none else sys.stdin + frames = bf.read_frames(src, max_frames) + if not frames: + return None + return bf.analyze_frames(frames) + + +def tone_freqs(ns, bw_code, center_mhz): + """Map subcarrier-group index 0..ns-1 to an absolute MHz. The report groups + the occupied band into ns bins; map them linearly across the bandwidth + centred on center_mhz (a coarse but honest mapping — the report does not + carry the exact 802.11 tone allocation). Resolution = bw/ns MHz per bin.""" + bw_mhz = 20 << bw_code + if center_mhz is None: + # No channel given — report offsets from band centre. + return [(k - (ns - 1) / 2.0) * bw_mhz / ns for k in range(ns)] + return [center_mhz + (k - (ns - 1) / 2.0) * bw_mhz / ns for k in range(ns)] + + +def mad(xs, med): + return statistics.median([abs(x - med) for x in xs]) or 1e-9 + + +def flag_high(vals, k): + """Indices whose value is > k MADs ABOVE the median (variance spikes).""" + med = statistics.median(vals) + m = mad(vals, med) + return [i for i, v in enumerate(vals) if v - med > k * m] + + +def flag_low(vals, k): + """Indices whose value is > k MADs BELOW the median (SNR notches).""" + med = statistics.median(vals) + m = mad(vals, med) + return [i for i, v in enumerate(vals) if med - v > k * m] + + +def contiguous(indices): + """Group a sorted index list into contiguous [lo,hi] runs.""" + runs = [] + for i in sorted(indices): + if runs and i == runs[-1][1] + 1: + runs[-1][1] = i + else: + runs.append([i, i]) + return runs + + +def bar(vals, width=52): + lo, hi = min(vals), max(vals) + span = (hi - lo) or 1.0 + ramp = " .:-=+*#%@" + return "".join(ramp[min(len(ramp) - 1, int((v - lo) / span * (len(ramp) - 1)))] + for v in vals), lo, hi + + +def self_test() -> int: + """Deterministic validation of the detection + frequency-mapping math on + synthetic per-tone arrays — independent of the (marginal, bench-limited) RF + regime. A narrowband interferer is modelled as a psi-variance spike / SNR + notch on a contiguous group of tones; assert we flag exactly that group and + map it to the right frequency.""" + ns, bw_code, ch = 52, 0, 100 # 20 MHz on ch100 (5500 MHz center) + center = 5000 + 5 * ch + freqs = tone_freqs(ns, bw_code, center) + fails = [] + + # 1) variance spike at tones 40..42 (an interferer near +5.6 MHz). + var = [0.010] * ns + for k in (40, 41, 42): + var[k] = 0.20 + hot = flag_high(var, 5.0) + if hot != [40, 41, 42]: + fails.append(f"variance spike: flagged {hot}, want [40,41,42]") + runs = contiguous(hot) + if runs != [[40, 42]]: + fails.append(f"contiguous: {runs}, want [[40,42]]") + cen = (freqs[40] + freqs[42]) / 2.0 + if not (5505.0 <= cen <= 5507.0): + fails.append(f"freq map: group centre {cen:.2f} MHz, want ~5506") + + # 2) SNR notch at tones 10..11 (interferer near -6 MHz). + snr = [25.0] * ns + snr[10] = snr[11] = 8.0 + dip = flag_low(snr, 4.0) + if dip != [10, 11]: + fails.append(f"SNR notch: flagged {dip}, want [10,11]") + + # 3) clean arrays flag nothing. + if flag_high([0.01] * ns, 5.0) or flag_low([25.0] * ns, 4.0): + fails.append("clean arrays produced a false positive") + + # 4) two separate interferers -> two groups. + v2 = [0.01] * ns + for k in (5, 6, 30): + v2[k] = 0.3 + if contiguous(flag_high(v2, 5.0)) != [[5, 6], [30, 30]]: + fails.append(f"two-group: {contiguous(flag_high(v2, 5.0))}") + + # 5) resolution is sub-channel (bw/ns). + res_khz = (20 << bw_code) * 1000.0 / ns + if not (300 <= res_khz <= 400): + fails.append(f"resolution {res_khz:.0f} kHz not ~385") + + if fails: + print("SELF-TEST FAILED:") + for f in fails: + print(" -", f) + return 1 + print(f"SELF-TEST PASSED: variance spike + SNR notch localized to the " + f"correct tone groups; {res_khz:.0f} kHz/bin sub-channel resolution; " + f"clean arrays clean; multi-group split works.") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("infile", nargs="?", help="interferer capture (default stdin)") + ap.add_argument("--baseline", help="clean baseline capture for differential mode") + ap.add_argument("--channel", type=int, default=None, + help="center channel (for absolute MHz in the report)") + ap.add_argument("--center-mhz", type=float, default=None, + help="center frequency MHz (overrides --channel mapping)") + ap.add_argument("--max-frames", type=int, default=400) + ap.add_argument("--snr-k", type=float, default=4.0, + help="flag a tone when SNR is this many MADs below the median") + ap.add_argument("--var-k", type=float, default=5.0, + help="flag a tone when psi-var is this many MADs above median") + ap.add_argument("--self-test", action="store_true", + help="deterministic check of the detection+localization math " + "(no hardware) — synthetic notch/spike at a known tone") + args = ap.parse_args() + + if args.self_test: + return self_test() + + a = load(args.infile, args.max_frames) + if a is None: + print("no beamforming reports on input", file=sys.stderr) + return 1 + + ns = a["ns"] + rows = a["rows"] # (k, psi, phi, ratio, psi_var) + psi_var = [r[4] for r in rows] + snr = a["mu_snr_db"] # per-tone SNR (MU) or None + + center = args.center_mhz + if center is None and args.channel is not None: + # 5 GHz: f = 5000 + 5*ch; 2.4 GHz: f = 2407 + 5*ch (ch14 = 2484). + center = (5000 + 5 * args.channel if args.channel >= 15 + else 2407 + 5 * args.channel) + freqs = tone_freqs(ns, a["bw"], center) + res_khz = (20 << a["bw"]) * 1000.0 / ns + funit = "MHz" if center is not None else "MHz-off-center" + + print(f"# per-tone localizer: SA={a['sa']} BW={20 << a['bw']}MHz Ng={a['ng']} " + f"Ns={ns} ({res_khz:.0f} kHz/bin) reports={a['nfr']} " + f"mu_reports={a['mu_reports']}") + + flagged = set() + + # --- psi cross-frame variance (always available) --- + vhot = flag_high(psi_var, args.var_k) + vline, vlo, vhi = bar(psi_var) + print(f"\n# per-tone psi cross-frame variance [{vlo:.4f}..{vhi:.4f}] " + f"(spikes = interferer-perturbed channel estimate):") + print(f" var: {vline}") + if vhot: + flagged.update(vhot) + + # --- MU per-tone SNR notch --- + if snr is not None and len(snr) >= 4: + m = min(ns, len(snr)) + s = snr[:m] + sdip = flag_low(s, args.snr_k) + sline, slo, shi = bar(s) + print(f"\n# per-tone SNR dB [{slo:.1f}..{shi:.1f}] " + f"(notch = interferer raising the noise floor):") + print(f" snr: {sline}") + if sdip: + flagged.update(sdip) + else: + print("\n# no MU per-tone SNR series (arm the beamformee with " + "DEVOURER_BF_ARM_BFEE_MU=1 for the SNR notch) — using psi-var only") + + # --- differential against a clean baseline --- + if args.baseline: + b = load(args.baseline, args.max_frames) + if b is None: + print("# baseline had no reports — skipping differential", + file=sys.stderr) + elif b["ns"] == ns: + bvar = [r[4] for r in b["rows"]] + dvar = [psi_var[k] - bvar[k] for k in range(ns)] + drise = flag_high(dvar, args.var_k) + dline, dlo, dhi = bar(dvar) + print(f"\n# differential psi-var (interferer - baseline) " + f"[{dlo:.4f}..{dhi:.4f}]:") + print(f" d: {dline}") + flagged.update(drise) + if b["mu_snr_db"] and snr: + mm = min(len(snr), len(b["mu_snr_db"]), ns) + dsnr = [snr[k] - b["mu_snr_db"][k] for k in range(mm)] + ddip = [k for k in range(mm) if dsnr[k] < -3.0] # >3 dB drop + flagged.update(ddip) + else: + print("# baseline geometry differs — skipping differential", + file=sys.stderr) + + # --- verdict --- + print() + if not flagged: + print("VERDICT: no localized interferer — per-tone metrics uniform " + "(clean band, or interferer covers the whole channel).") + return 0 + runs = contiguous(flagged) + print(f"VERDICT: interferer(s) localized to {len(runs)} tone group(s):") + for lo, hi in runs: + f_lo, f_hi = freqs[lo], freqs[hi] + span = abs(f_hi - f_lo) + res_khz / 1000.0 + cen = (f_lo + f_hi) / 2.0 + print(f" tones {lo}..{hi} ~{cen:.2f} {funit} " + f"(span ~{span:.2f} MHz, {hi - lo + 1} bins)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/rx_tone_localize.sh b/tests/rx_tone_localize.sh new file mode 100755 index 0000000..dd82f2b --- /dev/null +++ b/tests/rx_tone_localize.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Per-tone interferer localization from beamforming reports — the finest +# frequency sensor devourer has (sub-channel, vs the 20 MHz grid of +# rx_spectrum_sweep.sh). Runs the MU self-sounding rig and, optionally, parks a +# CW-tone interferer on a nearby channel so it lands off-centre within the +# sounded band; the sniffer's reports are localized by tests/rx_tone_localize.py. +# +# sounder : 8812AU (MU NDPA) +# beamformee: 8822CU (MU per-subcarrier SNR report) +# sniffer : 8814AU (captures reports -> localizer) +# interferer: optional (--tone-pid) CW tone on --tone-channel +# +# Two-capture (differential) is the most sensitive: run once clean, once with +# the tone, and diff. This script captures a clean baseline then, if --tone-pid +# is given, an interferer capture, and runs the localizer in differential mode. +# +# sudo tests/rx_tone_localize.sh --channel 100 +# sudo tests/rx_tone_localize.sh --channel 100 --tone-pid 0xc811 \ +# --tone-channel 102 --op-snr 28 +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(dirname "$HERE")" +TXDEMO="$ROOT/build/WiFiDriverTxDemo" +RXDEMO="$ROOT/build/WiFiDriverDemo" +LOC="$HERE/rx_tone_localize.py" + +CHANNEL=100 +BFER_MAC="57:42:75:05:d6:00" +BFEE_MAC="00:e0:4c:88:22:ce" +SOUNDER_PID=0x8812 BFEE_PID=0xc812 SNIFFER_PID=0x8813 +TONE_VID=0x0bda TONE_PID="" TONE_CHANNEL="" TONE_GAIN=10 +SECS=15 OUT=/tmp/devourer-tone-localize OP_SNR="" + +while [ $# -gt 0 ]; do + case "$1" in + --channel) CHANNEL="$2"; shift 2 ;; + --sounder-pid) SOUNDER_PID="$2"; shift 2 ;; + --bfee-pid) BFEE_PID="$2"; shift 2 ;; + --sniffer-pid) SNIFFER_PID="$2"; shift 2 ;; + --tone-vid) TONE_VID="$2"; shift 2 ;; + --tone-pid) TONE_PID="$2"; shift 2 ;; + --tone-channel) TONE_CHANNEL="$2"; shift 2 ;; + --tone-gain) TONE_GAIN="$2"; shift 2 ;; + --secs) SECS="$2"; shift 2 ;; + --op-snr) OP_SNR="$2"; shift 2 ;; + --outdir) OUT="$2"; shift 2 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done +[ -z "$TONE_CHANNEL" ] && TONE_CHANNEL="$CHANNEL" + +[ -x "$TXDEMO" ] && [ -x "$RXDEMO" ] || { echo "build demos first" >&2; exit 1; } +mkdir -p "$OUT" + +cleanup() { + for c in WiFiDriverTxDemo WiFiDriverDemo; do pkill -x "$c" 2>/dev/null || true; done + wait 2>/dev/null || true +} +trap cleanup EXIT INT TERM +cleanup; sleep 1 + +arm_rig() { + env DEVOURER_PID="$BFEE_PID" DEVOURER_CHANNEL=$CHANNEL \ + DEVOURER_BF_ARM_BFEE="$BFER_MAC" DEVOURER_BF_ARM_BFEE_MU=1 \ + "$RXDEMO" > "$OUT/bfee.log" 2>&1 & + for _ in $(seq 80); do grep -q "entering RX loop" "$OUT/bfee.log" && break; sleep 0.5; done + env DEVOURER_PID="$SOUNDER_PID" DEVOURER_CHANNEL=$CHANNEL \ + DEVOURER_TX_RATE=VHT2SS_MCS0 DEVOURER_TX_NDPA_RA="$BFEE_MAC" \ + DEVOURER_TX_NDPA=1 DEVOURER_TX_NDPA_MU=1 DEVOURER_BF_ARM_SOUNDER=1 \ + "$TXDEMO" > "$OUT/sounder.log" 2>&1 & + sleep 2 +} + +capture() { # $1 = output capture file + timeout -sINT "$((SECS + 3))" env DEVOURER_PID="$SNIFFER_PID" \ + DEVOURER_CHANNEL=$CHANNEL DEVOURER_BF_DETECT_REPORT=4 "$RXDEMO" 2>/dev/null \ + | grep --line-buffered "devourer-bf-report-raw" > "$1" || true +} + +echo "== arming MU self-sounding rig (ch $CHANNEL, bfee init ~20s) ==" +arm_rig + +echo "== clean baseline capture (${SECS}s) ==" +capture "$OUT/clean.txt" +echo " $(wc -l < "$OUT/clean.txt") reports" + +if [ -n "$TONE_PID" ]; then + echo "== interferer ON: $TONE_PID CW tone ch$TONE_CHANNEL gain $TONE_GAIN ==" + timeout -sINT "$((SECS + 20))" env DEVOURER_VID="$TONE_VID" \ + DEVOURER_PID="$TONE_PID" DEVOURER_CHANNEL="$TONE_CHANNEL" \ + DEVOURER_CW_TONE=1 DEVOURER_CW_TONE_GAIN="$TONE_GAIN" \ + "$TXDEMO" > "$OUT/tone.emit.log" 2>&1 & + # CW bring-up can take ~6 s (IQK); poll up to 15 s for the arm line. + for _ in $(seq 30); do + grep -q "single-tone armed" "$OUT/tone.emit.log" && break; sleep 0.5 + done + grep -q "single-tone armed" "$OUT/tone.emit.log" && echo " tone armed" \ + || echo " WARN: tone not armed (see $OUT/tone.emit.log)" + capture "$OUT/interf.txt" + echo " $(wc -l < "$OUT/interf.txt") reports" +fi + +cleanup + +echo "== localize ==" +loc_args=(--channel "$CHANNEL") +if [ -n "$TONE_PID" ] && [ -s "$OUT/interf.txt" ]; then + python3 "$LOC" "$OUT/interf.txt" --baseline "$OUT/clean.txt" "${loc_args[@]}" +else + python3 "$LOC" "$OUT/clean.txt" "${loc_args[@]}" +fi diff --git a/tools/bf_report_decode.py b/tools/bf_report_decode.py index 7b1fcf0..ce8ec29 100644 --- a/tools/bf_report_decode.py +++ b/tools/bf_report_decode.py @@ -179,22 +179,8 @@ def decode_angles(angle_bytes: bytes, ns: int, na: int, bphi: int, bpsi: int, 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") - ap.add_argument("--operating-snr", type=float, default=None, - help="re-centre the MEASURED per-tone SNR shape so its mean " - "= this dB (models a weaker/longer-range link at the " - "same frequency-selectivity — shows the QAM ladder)") - args = ap.parse_args() - - global _MSB - _MSB = args.msb - src = open(args.infile) if args.infile else sys.stdin +def read_frames(src, max_frames=200): + """Parse `` (or bare hex) lines into frame dicts.""" frames = [] for line in src: line = line.strip() @@ -203,44 +189,19 @@ def main() -> int: f = parse_frame(line) if f: frames.append(f) - if len(frames) >= args.max_frames: + if len(frames) >= max_frames: break - if not frames: - print("no VHT/HT compressed-beamforming reports found", file=sys.stderr) - return 1 + return frames - 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) - 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 - # For an MU report the angle field is followed by the MU Exclusive report, - # so the V-angle byte count is NOT the whole tail — it is Ns x the compact - # codebook (10 bits/subcarrier for Realtek's 2x1/2x2). Slice to just the V - # angles so the split logic sees the right budget; the rest is per-tone SNR. - if f0["feedback"]: - vbytes0 = (ns * 10 + 7) // 8 - for f in frames: - f["angle_bytes"] = f["angle_bytes"][:vbytes0] - nbits = len(f0["angle_bytes"]) * 8 - 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. +def select_split(frames, ns, na, layout, per_sc_bits): + """Choose the (b_phi, b_psi) angle bit-split by cross-frame stability. + + Returns (bphi, bpsi, scored) where scored is a list of + (bphi, bpsi, xframe_var, tone_corr) for every candidate. The correct split + decodes a quasi-static channel to repeatable angles (low cross-frame var); + a wrong split smears the LSBs frame-to-frame. Tone correlation only helps on + a frequency-selective channel, so cross-frame var is the discriminator.""" nphi, npsi = layout.count("phi"), layout.count("psi") candidates = [] for bphi in range(2, per_sc_bits - 1): @@ -254,8 +215,6 @@ def main() -> int: 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) @@ -270,9 +229,6 @@ def stability(bphi, bpsi): 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) @@ -287,34 +243,42 @@ def smoothness(bphi, bpsi): 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. + scored = [(bphi, bpsi, stability(bphi, bpsi), smoothness(bphi, bpsi)) + for (bphi, bpsi) in candidates] 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.") + return best[0], best[1], scored + + +def analyze_frames(frames, operating_snr=None): + """Decode a list of BF-report frames into per-tone metrics. Returns a dict + with the geometry (ns/nr/nc/bw/ng), the chosen split, per-stream SNR, the + per-tone rows (k, psi, phi, |h_B/h_A|, cross-frame psi variance), and — for + an MU report — the per-tone SNR curve in dB. The single source of truth for + both the decoder CLI and the interference localizer. Returns None on an + undecodable/empty capture.""" + if not frames: + return None + f0 = frames[0] + nr, nc, bw, ng = f0["nr"], f0["nc"], f0["bw"], f0["ng"] + ns = NS_TABLE.get(bw, {}).get(ng) + if not ns: + return None + layout = angle_layout(nr, nc) + na = len(layout) + # An MU report appends the MU Exclusive (per-tone SNR) report after the V + # angles; slice to just the V angles (Ns x the 10-bit compact codebook) so + # the split logic sees the right budget. + if f0["feedback"]: + vbytes0 = (ns * 10 + 7) // 8 + for f in frames: + f["angle_bytes"] = f["angle_bytes"][:vbytes0] + nbits = len(f0["angle_bytes"]) * 8 + per_sc_bits_f = nbits / ns + per_sc_bits = int(per_sc_bits_f) + + bphi, bpsi, scored = select_split(frames, ns, na, layout, per_sc_bits) - # 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) @@ -327,20 +291,89 @@ def smoothness(bphi, bpsi): 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 + ratio = math.tan(psi) + var = sum((x - psi) ** 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] + snr_db = [(22.0 + 0.25 * (b if b < 128 else b - 256)) for b in f0["snr"]] + + # MU per-tone SNR curve. + vbytes = len(f0["angle_bytes"]) + if f0["feedback"]: + vbytes = (ns * per_sc_bits + 7) // 8 + mu = [m for m in (parse_mu_snr(f, ns, vbytes) for f in frames) if m] + mu_dbs = mu_shift = None + if mu: + n = min(len(m) for m in mu) + avg = [sum(m[k] for m in mu) / len(mu) for k in range(n)] + mu_dbs = [u8_snr_db(v) for v in avg] + if operating_snr is not None: + mu_shift = operating_snr - (sum(mu_dbs) / len(mu_dbs)) + mu_dbs = [d + mu_shift for d in mu_dbs] + + return dict(sa=f0["sa"], nr=nr, nc=nc, bw=bw, ng=ng, ns=ns, + codebook=f0["codebook"], feedback=f0["feedback"], + bphi=bphi, bpsi=bpsi, scored=scored, per_sc_bits=per_sc_bits, + per_sc_bits_f=per_sc_bits_f, angle_len=len(f0["angle_bytes"]), + nbits=nbits, na=na, nfr=nfr, rows=rows, per_stream_snr_db=snr_db, + mu_reports=len(mu), mu_snr_db=mu_dbs, mu_shift=mu_shift) + + +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") + ap.add_argument("--operating-snr", type=float, default=None, + help="re-centre the MEASURED per-tone SNR shape so its mean " + "= this dB (models a weaker/longer-range link at the " + "same frequency-selectivity — shows the QAM ladder)") + args = ap.parse_args() + + global _MSB + _MSB = args.msb + src = open(args.infile) if args.infile else sys.stdin + frames = read_frames(src, args.max_frames) + if not frames: + print("no VHT/HT compressed-beamforming reports found", file=sys.stderr) + return 1 + + a = analyze_frames(frames, args.operating_snr) + print(f"# {len(frames)} reports SA={a['sa']} Nr={a['nr']} Nc={a['nc']} " + f"BW={20 << a['bw']}MHz Ng={a['ng']} codebook={a['codebook']} " + f"feedback={'MU' if a['feedback'] else 'SU'}") + if a is None or not a["ns"]: + print(f"# unknown Ns", file=sys.stderr) + return 1 + print(f"# angle payload {a['angle_len']} B = {a['nbits']} bits, " + f"Ns={a['ns']} -> {a['per_sc_bits_f']:.2f} bits/subcarrier over " + f"{a['na']} angle(s)") + if a["per_sc_bits_f"] != int(a["per_sc_bits_f"]): + print("# WARN: non-integer bits/subcarrier — Ns or Na guess wrong", + file=sys.stderr) + + print("# candidate split validation (want low cross-frame var AND high " + "adjacent-tone corr):") + print("# b_phi b_psi xframe_var tone_corr") + for (bphi, bpsi, v, s) in a["scored"]: + print(f"# {bphi:5d} {bpsi:5d} {v:10.5f} {s:+.3f}") + best = min(a["scored"], key=lambda t: t[2]) + print(f"# chose b_phi={a['bphi']} b_psi={a['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.") + + rows = a["rows"] 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"{', '.join(f'{s:.2f}' for s in a['per_stream_snr_db'])}") + print(f"# per-tone relative channel |h_B/h_A| across {a['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 @@ -357,26 +390,19 @@ def smoothness(bphi, bpsi): fh.write(f"{k},{psi:.5f},{phi:.5f},{ratio:.5f},{var:.6f}\n") print(f"# wrote {args.csv}") - # --- MU per-tone SNR -> QAM (the textbook per-subcarrier picture) --- - vbytes = len(f0["angle_bytes"]) # SU baseline V-angle size for this BW - if f0["feedback"]: # MU: V-angles are only the first vbytes - vbytes = (ns * per_sc_bits + 7) // 8 - mu = [parse_mu_snr(f, ns, vbytes) for f in frames] - mu = [m for m in mu if m] - if mu: - n = min(len(m) for m in mu) - avg = [sum(m[k] for m in mu) / len(mu) for k in range(n)] - dbs = [u8_snr_db(v) for v in avg] + if a["mu_snr_db"] is not None: + dbs = a["mu_snr_db"] + n = len(dbs) print(f"\n# MU Exclusive Beamforming Report — per-subcarrier SNR " - f"({n} groups across {20 << bw} MHz, {len(mu)} reports)") - print(f"# measured SNR range {min(dbs):.1f}..{max(dbs):.1f} dB " - f"(swing {max(dbs) - min(dbs):.1f} dB)") - if args.operating_snr is not None: - shift = args.operating_snr - (sum(dbs) / len(dbs)) - dbs = [d + shift for d in dbs] + f"({n} groups across {20 << a['bw']} MHz, {a['mu_reports']} reports)") + # The measured range (pre-shift) is recoverable by subtracting the shift. + raw = [d - (a["mu_shift"] or 0.0) for d in dbs] + print(f"# measured SNR range {min(raw):.1f}..{max(raw):.1f} dB " + f"(swing {max(raw) - min(raw):.1f} dB)") + if a["mu_shift"] is not None: print(f"# re-centred to operating mean {args.operating_snr:.0f} dB " - f"(measured shape shifted {shift:+.1f} dB — models a weaker " - f"link; the per-tone *shape* is real)") + f"(measured shape shifted {a['mu_shift']:+.1f} dB — models a " + f"weaker link; the per-tone *shape* is real)") from collections import Counter dist = Counter(snr_to_qam(d)[0].strip() for d in dbs) print("# freq → (each column = one subcarrier group, low → high)") @@ -384,7 +410,7 @@ def smoothness(bphi, bpsi): print("# QAM: " + " ".join(snr_to_qam(d)[1] for d in dbs)) print(f"# per-tone modulation: " f"{', '.join(f'{k}:{v}' for k, v in dist.items())}") - elif f0["feedback"]: + elif a["feedback"]: print("# MU report flagged but no per-tone SNR series recovered") return 0