From 0cb587ff8c271b5d69dbecec3c7d318b7330d871 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:23:41 +0300 Subject: [PATCH] LA-mode IQ capture: per-tone CSI from Jaguar silicon, offline H(k) tools, SDR notch validation (#150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One shared module (src/LaCapture) ports phydm's logic-analyzer ADC capture to all three generations: the baseband DMAs a raw sample bus into the top of the TX packet buffer, host-read through the 0x0140-page window. MAC half (0x7c0 trigger/poll/readback) is identical across families; the BB half is two register dialects (11AC 0x95c/0x8fc, JGR3 0x1ce4/0x1cf4/0x1c3c) carried by per-chip LaRegs. Wired as a research method on each device class + rxdemo env knobs (DEVOURER_LA_CAPTURE, USB and PCIe paths), with BbDbgportReader-grade safety: full register snapshot/restore, liveness checks, sticky wedge latch. Hardware-bisected deviations from the vendor flow: the manual REG trigger edge (0x7c0[5]) must be pulsed after the dump-enable bit or the capture never completes; dbg_port routing selects the sampled bus and 0 is dead (ADC pairing is dma0+0x880 / dma1+0xa80); 0x0106 TXFF debug access is restored instead of leaked. Sample packing established empirically (tests/la_cw_score.sh, B210 CW at known offsets): one complex sample per 64-bit word, 12-bit two's complement, I=[11:0] Q=[23:12] (qi12_l) on every chip, at the configured rate; JGR3 carries the second RX path in the high dword — a coherent two-chain capture per trigger. tools/la_decode.py scores candidate layouts; tools/la_csi.py locates the L-LTF (STF-rejecting), refines CFO over the repetition train, and FFTs to per-tone H(k) mag/phase, with a synthetic 2-tap-channel ctest (la_csi_math). Validation: tests/la_capture_smoke.sh 3/3 on 8822BU / 8814AU / 8821C / 8822CU / 8822EU, plus the 8821CE over PCIe (8 ms MMIO readback vs ~2 s USB); 8812A/8821A report la.nosupport (0x7c0 never latches — the silicon lacks the block, matching PHYDM_IC_SUPPORT_LA_MODE). tests/la_sdr_crosscheck.sh validates H(k) against SDR ground truth via a notch protocol — the B210 transmits an L-LTF train with an asymmetric tone set zeroed and the chip's estimate must reproduce it (8822BU: 21-31 dB depth, clean-tone ripple 1.2 dB). Chip-vs-SDR |H(k)| correlation was measured and rejected as a methodology: receivers at different antennas see independent fading (~0 correlation, both sides self-consistent). Known J3 caveat: ~8 dB adjacent-tone leakage floor on both paths (common LO); features two or more tones wide resolve at 15-23 dB. Closes #150 Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 6 + CMakeLists.txt | 14 ++ docs/la-capture.md | 114 +++++++++++ examples/rx/main.cpp | 271 ++++++++++++++++++++++++ src/LaCapture.cpp | 340 +++++++++++++++++++++++++++++++ src/LaCapture.h | 180 ++++++++++++++++ src/jaguar1/BbDbgportReader.h | 28 ++- src/jaguar1/RtlJaguarDevice.cpp | 19 ++ src/jaguar1/RtlJaguarDevice.h | 11 + src/jaguar2/RtlJaguar2Device.cpp | 20 ++ src/jaguar2/RtlJaguar2Device.h | 11 + src/jaguar3/RtlJaguar3Device.cpp | 10 + src/jaguar3/RtlJaguar3Device.h | 11 + tests/la_capture_smoke.sh | 125 ++++++++++++ tests/la_cw_score.sh | 46 +++++ tests/la_sdr_compare.py | 287 ++++++++++++++++++++++++++ tests/la_sdr_crosscheck.sh | 58 ++++++ tools/la_csi.py | 267 ++++++++++++++++++++++++ tools/la_decode.py | 208 +++++++++++++++++++ 19 files changed, 2011 insertions(+), 15 deletions(-) create mode 100644 docs/la-capture.md create mode 100644 src/LaCapture.cpp create mode 100644 src/LaCapture.h create mode 100755 tests/la_capture_smoke.sh create mode 100755 tests/la_cw_score.sh create mode 100644 tests/la_sdr_compare.py create mode 100755 tests/la_sdr_crosscheck.sh create mode 100644 tools/la_csi.py create mode 100644 tools/la_decode.py diff --git a/CLAUDE.md b/CLAUDE.md index da20c34..17e023d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -248,6 +248,12 @@ Knob-specific facts that aren't obvious from the field docs: `crc_err`/`icv_err` set — the entry point for the fused-FEC salvage layer (`docs/fused-fec.md`). Opt-in: a body with a corrupt tail is the worst-case input for an IP-stack consumer that didn't ask for it. +- `DEVOURER_LA_CAPTURE=/M/dma0/port:0x880` (rxdemo) — one-shot + LA-mode IQ capture into the TX packet buffer (`src/LaCapture.h`): raw + complex baseband to a `DVLA` file, offline per-tone H(k) via + `tools/la_csi.py`. 8814A/8822B/8821C/8822C/8822E (+ 8821CE PCIe); the + 8812A/8821A have no LA block. Sample packing, per-chip windows, trigger + semantics, validation scripts and wedge risks: `docs/la-capture.md`. - `DEVOURER_THERMAL_POLL_MS=N` emits `thermal` events from the RF 0x42 meter: `raw` is 0..63 thermal units (~1.5–2 °C each, **not** absolute °C — hence bucketed status, not a fake temperature), `delta` = raw − diff --git a/CMakeLists.txt b/CMakeLists.txt index f2f6d34..c0347c7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -140,6 +140,10 @@ add_library(devourer src/WiFiDriver.cpp src/WiFiDriver.h src/registry_priv.h + # LA-mode (phydm logic-analyzer) IQ capture — one implementation serves + # all three generations (LaRegs carries the per-chip parameters). + src/LaCapture.cpp + src/LaCapture.h # phydm check_positive table walker — shared by Jaguar1 and Jaguar2 # (both use the older addr/value + check_positive table format). src/PhyTableLoader.cpp @@ -624,6 +628,16 @@ if(Python3_Interpreter_FOUND) COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/rx_tone_localize.py --self-test ) + + # Headless guard for the LA-capture CSI math (tools/la_csi.py): synthetic + # L-LTF through a known 2-tap channel + CFO + AWGN must locate, correct, + # and recover |H(k)| with correlation > 0.99. Skips (77) without numpy. + add_test( + NAME la_csi_math + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tools/la_csi.py --selftest + ) + set_tests_properties(la_csi_math PROPERTIES SKIP_RETURN_CODE 77) endif() # --- selftest aggregate ---------------------------------------------------- diff --git a/docs/la-capture.md b/docs/la-capture.md new file mode 100644 index 0000000..ac2ba36 --- /dev/null +++ b/docs/la-capture.md @@ -0,0 +1,114 @@ +# LA-mode IQ capture — per-tone CSI from Jaguar silicon + +No Jaguar-class chip exports per-subcarrier CSI through a normal host +path. phydm's **LA (logic-analyzer / ADC-sampling) mode** is the debug +escape hatch: the baseband DMAs a raw sample bus into the top of the TX +packet buffer, and the host reads it back through the `0x0140`-page + +`0x8000`-window. An offline FFT of a captured L-LTF **is** the full +per-tone channel estimate H(k) — one-shot and slow, but it turns a dongle +into a per-subcarrier channel sounder. Implementation: +`src/LaCapture.{h,cpp}` (one algorithm, per-family `LaRegs` — the +NhmReader pattern); vendored source: `reference/rtl88x2bu` (11AC dialect) +and `reference/rtl88x2cu`/`rtl88x2eu` (JGR3) `hal/phydm/phydm_adc_sampling.c` ++ `phydm_debug.c`. + +## Silicon support + +`PHYDM_IC_SUPPORT_LA_MODE` (phydm_pre_define.h): **8814A, 8822B, 8821C, +8822C, 8822E**. The **8812A and 8821A have no LA block** — `0x7c0` never +latches the arm bits there and the module reports `no_la_block` +(`la.nosupport` event). Bench-validated on all five supported chips +(USB) plus the **8821CE over PCIe** (same module through `RtlAdapter`; +readback 8 ms via MMIO vs ~2 s over USB control transfers). + +| chip | TXFF window | size (HALF mode) | +|---|---|---| +| 8814A | 0x30000..0x40000 | 64 KB | +| 8822B / 8822C / 8822E | 0x20000..0x40000 | 128 KB | +| 8821C / 8821CE | 0x8000..0x10000 | 32 KB (`0x7cc[30]` doubles it) | + +## Usage + +```sh +# one-shot capture, CCA-triggered, 20 Msps, ADC bus path A: +DEVOURER_LA_CAPTURE=cca/20M/dma0/port:0x880/t200 \ +DEVOURER_LA_OUT=/tmp/la.bin DEVOURER_LA_MAX=8192 build/rxdemo + +python3 tools/la_decode.py score /tmp/la.bin --offset-mhz 4 # layout scoring +python3 tools/la_decode.py plot /tmp/la.bin # PSD + |x| +python3 tools/la_csi.py /tmp/la.bin --plot h.png # per-tone H(k) +``` + +Spec grammar: `[/M][/dma][/port:0xNNN[.hdr][.bit]]` +`[/edge<0|1>][/t][/all]` — `` ∈ `manual|crcok|crcfail|cca|bb|mac`. +`t` is the post-trigger time; the rest of the ring is pre-trigger, so +a `crcok` capture (trigger = frame **end**) holds the whole frame +including the preamble. `DEVOURER_LA_MAX` caps readback and keeps the +**newest** samples (the trigger vicinity). `DEVOURER_LA_DELAY_MS` +(default 2000) is the arm delay after RX is live. The demo writes a +32-byte `DVLA` header + LE u64 records; events: `la.capture`, +`la.timeout`, `la.nosupport`, `la.wedged`. + +## Sample format (established empirically — tests/la_cw_score.sh) + +At `dma0/port:0x880`, every chip packs **one complex sample per 64-bit +word**: 12-bit two's complement, **I=[11:0], Q=[23:12]** of the low dword +(`qi12_l` in tools/la_decode.py — the naive I/Q order is spectrally +inverted), at exactly the configured sample rate. JGR3 (8822C/E) +additionally carries the **second RX path** in the high dword +(I=[55:44], Q=[43:32], `iq12_h`) — a coherent two-chain capture in one +trigger. CW line scores (peak/median): 8822CU 50.8 dB, 8821C 36.8 dB, +8822BU 23.6 dB, 8814AU 19.1 dB. + +## Hardware findings the vendor code hides + +- **Manual REG trigger ordering**: the `0x7c0[5]` 0→1 edge must be pulsed + **after** the dump-enable bit `0x7c0[1]` is set. The vendor sets it + earlier in `phydm_la_set_mac_iq_dump`; that order never completes on + real 8822BU silicon (edge consumed before arm). +- **dma_type alone doesn't select a live bus** — the BB dbg-port value + routes the sampled bus, and `dbg_port=0` is a dead bus (clean poll + timeout). ADC pairing: `dma0`+`0x880` (path A), `dma1`+`0xa80` (path B). +- **8821C cut A** has no LA clock bit (`0x95c[23]` skipped, like vendor + `phydm_la_clk_en`); expect a poll timeout there. +- **TX-buffer conflict**: capture windows occupy the top of the TXFF and + the smoke test's capture→TX→capture cell passes, but don't run a + capture concurrent with heavy TX. The vendor flow leaks `0x0106=0x69` + (TXFF debug access); LaCapture restores it. + +## Validation + +- `tests/la_capture_smoke.sh [ch] [tx_pid] [vid]` — per-chip: + manual trigger, crcok trigger, capture→TX→capture. 3/3 PASS on + 8822BU, 8814AU, 8821C(8811CU), 8822CU, 8822EU. +- `tests/la_cw_score.sh` — layout scoring against a B210 CW at a known + offset (layout-scoring methodology). +- `tests/la_sdr_crosscheck.sh [ch] [vid] [min_notch]` — the + **notch protocol**: the B210 transmits an L-LTF train with an + asymmetric tone set zeroed; the chip's H(k) must reproduce the notch at + exactly those tones (local-neighbour depth). 8822BU: 21–31 dB on all + six tones. **Chip-vs-SDR |H(k)| correlation was refuted as a + methodology**: receivers at different antennas see independent fading + (measured ~0 correlation with both sides self-consistent). +- `ctest` `la_csi_math` — synthetic L-LTF through a 2-tap channel + CFO + + AWGN; |H(k)| recovery correlation > 0.99. + +## Per-chip CSI quality caveats + +- **8822CU (Jaguar3)**: ~8 dB adjacent-tone leakage floor on **both** RX + paths (common LO — phase-noise/capture-path spreading; unchanged by FFT + window backoff or multi-period CFO refinement). Single-tone features + bound at ~5–8 dB at their edges; features ≥2 tones wide resolve at + 15–23 dB. Pass the crosscheck with `MIN_NOTCH=5`. +- The B210 helper (`tests/la_sdr_compare.py txltf`) transmits the LTF + train as long precomputed buffers — per-burst `send()` calls underrun + from python and nothing airs. + +## Risks + +Same class as `BbDbgportReader` (0x8fc/0x95c/0x1c3c writes on a live +demod): every touched register is snapshotted and restored, chip liveness +is checked between phases, and a dead read latches the module wedged +(`la.wedged`). Recovery ladder: `libusb_reset_device` → port-level +usbreset → VBUS power-cycle. Readback cost: two vendor-control reads per +8-byte sample (~1 s per 1 K samples on USB2; MMIO-fast on PCIe). diff --git a/examples/rx/main.cpp b/examples/rx/main.cpp index 00f91c5..125b8ce 100644 --- a/examples/rx/main.cpp +++ b/examples/rx/main.cpp @@ -1,8 +1,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -13,6 +15,7 @@ #include "BfReportDetect.h" #include "HopSchedule.h" +#include "LaCapture.h" #include "LinkHealth.h" #include "RxPacket.h" #include "SweepSpec.h" @@ -20,6 +23,12 @@ #if defined(DEVOURER_HAVE_JAGUAR1) #include "jaguar1/RtlJaguarDevice.h" #endif +#if defined(DEVOURER_HAVE_JAGUAR2) +#include "jaguar2/RtlJaguar2Device.h" +#endif +#if defined(DEVOURER_HAVE_JAGUAR3) +#include "jaguar3/RtlJaguar3Device.h" +#endif #include "RtlAdapter.h" #include "SignalStop.h" #include "UsbOpen.h" @@ -155,6 +164,196 @@ static const std::vector g_csi_selectors = []() -> std::vector[/M][/dma][/port:0xNNN[.hdr][.bit]] + * [/edge<0|1>][/t][/all] + * One-shot LA-mode (phydm logic-analyzer) IQ capture into the TX packet + * buffer, dumped to DEVOURER_LA_OUT (default /tmp/la_capture.bin) as a + * 32-byte "DVLA" header + little-endian u64 records (tools/la_decode.py). + * : manual (immediate) | crcok | crcfail | cca (MAC-event ADC + * triggers) | bb (BB dbg-port bit, needs /port+.bit) | mac (MAC dbg dump). + * M: 80M 40M 20M 10M 5M 2.5M 1.25M 160M (default 20M). + * Runs once from a worker thread after RX is live (+DEVOURER_LA_DELAY_MS, + * default 2000, so bring-up/calibration settles); DEVOURER_LA_MAX caps the + * readback sample count (readback is 2 control-reads per 8-byte sample — + * a full 128 KB window takes tens of seconds on USB2). + * Supported: 8814A / 8822B / 8821C(cut B+) / 8822C / 8822E. The 8812A and + * 8821A have no LA block (vendor support macro) — a probe there exits with + * la.timeout. BRICK RISK: same class as DEVOURER_RX_DUMP_CSI; the module + * save/restores every touched register and self-wedges loudly (la.wedged) + * if the chip stops responding. Not combinable with DEVOURER_RX_SWEEP / + * lockstep hopping (those paths exit before the capture thread starts). */ +static const char *g_la_spec = std::getenv("DEVOURER_LA_CAPTURE"); +static const char *g_la_out = []() { + const char *e = std::getenv("DEVOURER_LA_OUT"); + return (e && *e) ? e : "/tmp/la_capture.bin"; +}(); +static const uint32_t g_la_delay_ms = []() -> uint32_t { + const char *e = std::getenv("DEVOURER_LA_DELAY_MS"); + return (e && *e) ? static_cast(std::strtoul(e, nullptr, 0)) : 2000; +}(); +static const uint32_t g_la_max = []() -> uint32_t { + const char *e = std::getenv("DEVOURER_LA_MAX"); + return (e && *e) ? static_cast(std::strtoul(e, nullptr, 0)) : 0; +}(); + +/* Parse the DEVOURER_LA_CAPTURE spec. Returns false (+logs) on a token it + * doesn't recognize. */ +static bool parse_la_spec(const char *spec, devourer::LaParams &p) { + std::string s = spec; + size_t pos = 0; + bool first = true; + while (pos <= s.size()) { + size_t slash = s.find('/', pos); + std::string tok = s.substr( + pos, slash == std::string::npos ? std::string::npos : slash - pos); + if (first) { + first = false; + if (tok == "manual") { + p.trig_mode = devourer::LaTrigMode::AdcMacTrig; + p.mac_sig = devourer::LaMacSig::Manual; + } else if (tok == "crcok") { + p.trig_mode = devourer::LaTrigMode::AdcMacTrig; + p.mac_sig = devourer::LaMacSig::CrcOk; + } else if (tok == "crcfail") { + p.trig_mode = devourer::LaTrigMode::AdcMacTrig; + p.mac_sig = devourer::LaMacSig::CrcFail; + } else if (tok == "cca") { + p.trig_mode = devourer::LaTrigMode::AdcMacTrig; + p.mac_sig = devourer::LaMacSig::Cca; + } else if (tok == "bb") { + p.trig_mode = devourer::LaTrigMode::BbTrig; + } else if (tok == "mac") { + p.trig_mode = devourer::LaTrigMode::MacDump; + } else { + return false; + } + } else if (!tok.empty()) { + if (tok.back() == 'M') { + static const char *rates[] = {"80M", "40M", "20M", "10M", + "5M", "2.5M", "1.25M", "160M"}; + bool hit = false; + for (int i = 0; i < 8; i++) + if (tok == rates[i]) { + p.smp_rate = static_cast(i); + hit = true; + } + if (!hit) + return false; + } else if (tok.rfind("dma", 0) == 0) { + p.dma_type = static_cast(std::strtoul(tok.c_str() + 3, + nullptr, 0)); + } else if (tok.rfind("port:", 0) == 0) { + /* port:0xNNN[.hdr][.bit] */ + std::string rest = tok.substr(5); + size_t dot; + while ((dot = rest.rfind('.')) != std::string::npos) { + std::string sub = rest.substr(dot + 1); + rest.resize(dot); + if (sub.rfind("hdr", 0) == 0) + p.hdr_sel = static_cast(std::strtoul(sub.c_str() + 3, + nullptr, 0)); + else if (sub.rfind("bit", 0) == 0) + p.trig_sel = static_cast(std::strtoul(sub.c_str() + 3, + nullptr, 0)); + else + return false; + } + p.dbg_port = static_cast(std::strtoul(rest.c_str(), + nullptr, 0)); + } else if (tok.rfind("edge", 0) == 0) { + p.edge = static_cast(std::strtoul(tok.c_str() + 4, + nullptr, 0)) & 1; + } else if (tok[0] == 't') { + p.trigger_time_us = static_cast(std::strtoul(tok.c_str() + 1, + nullptr, 0)); + } else if (tok == "all") { + p.buff_all = true; + } else { + return false; + } + } + if (slash == std::string::npos) + break; + pos = slash + 1; + } + return true; +} + +/* Dispatch la_capture to whichever generation this device is (research + * helpers are concrete-type methods, not on IRtlDevice). Returns an + * empty function when the generation has no LA support wired yet. */ +static std::function +la_runner_for(IRtlDevice *dev) { +#if defined(DEVOURER_HAVE_JAGUAR1) + if (auto *j1 = dynamic_cast(dev)) + return [j1](const devourer::LaParams &p) { return j1->la_capture(p); }; +#endif +#if defined(DEVOURER_HAVE_JAGUAR2) + if (auto *j2 = dynamic_cast(dev)) + return [j2](const devourer::LaParams &p) { return j2->la_capture(p); }; +#endif +#if defined(DEVOURER_HAVE_JAGUAR3) + if (auto *j3 = dynamic_cast(dev)) + return [j3](const devourer::LaParams &p) { return j3->la_capture(p); }; +#endif + (void)dev; + return {}; +} + +/* Run the one-shot capture, dump the buffer, emit la.* events. */ +static void run_la_capture( + const std::function &runner, + const devourer::LaParams &p) { + const auto t0 = std::chrono::steady_clock::now(); + devourer::LaResult r = runner(p); + const long long ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0) + .count(); + if (r.wedged) { + devourer::Ev(*g_ev, "la.wedged"); + return; + } + if (r.no_la_block) { + devourer::Ev(*g_ev, "la.nosupport").f("ms", ms); + return; + } + if (r.poll_timeout) { + devourer::Ev(*g_ev, "la.timeout").f("ms", ms); + return; + } + if (r.ok) { + /* 32-byte self-describing header + LE u64 records (tools/la_decode.py). */ + FILE *f = std::fopen(g_la_out, "wb"); + if (f) { + uint8_t hdr[32] = {'D', 'V', 'L', 'A', 1 /*ver*/}; + hdr[5] = static_cast(p.trig_mode); + hdr[6] = static_cast(p.mac_sig); + hdr[7] = p.smp_rate; + hdr[8] = p.dma_type; + hdr[9] = r.round_up ? 1 : 0; + const uint32_t n = static_cast(r.samples.size()); + std::memcpy(hdr + 12, &n, 4); + std::memcpy(hdr + 16, &r.finish_addr, 4); + std::memcpy(hdr + 20, &p.trigger_time_us, 4); + std::fwrite(hdr, 1, sizeof(hdr), f); + std::fwrite(r.samples.data(), 8, r.samples.size(), f); + std::fclose(f); + } + static const int kRateMhz10[] = {800, 400, 200, 100, 50, 25, 12, 1600}; + devourer::Ev(*g_ev, "la.capture") + .f("ok", 1) + .f("samples", r.samples.size()) + .hexf("finish", r.finish_addr, 4) + .f("wrap", r.round_up ? 1 : 0) + .f("rate_mhz10", kRateMhz10[p.smp_rate & 7]) + .f("dma", p.dma_type) + .f("file", f ? g_la_out : "") + .f("ms", ms); + } else { + devourer::Ev(*g_ev, "la.capture").f("ok", 0).f("ms", ms); + } +} + /* DEVOURER_RX_ENERGY_MS=N: periodic frame-free RX energy / channel-busy * telemetry — the read side of DEVOURER_CW_TONE. Each interval emits one * rx.energy event combining the chip's phydm FA/CCA counters + IGI @@ -645,6 +844,37 @@ int main() { } } logger->info("PCIe RX: {} ch={} bw={}", bdf, pch, (int)pwidth); + /* DEVOURER_LA_CAPTURE rides the same worker-thread pattern as the USB + * default path — the LA module is bus-neutral (RtlAdapter). */ + std::thread pcie_la_thread; + if (g_la_spec && *g_la_spec) { + devourer::LaParams la_params; + la_params.max_samples = g_la_max; + if (!parse_la_spec(g_la_spec, la_params)) { + logger->error("DEVOURER_LA_CAPTURE: bad spec '{}'", g_la_spec); + return 1; + } + auto runner = la_runner_for(dev.get()); + if (!runner) { + logger->error("DEVOURER_LA_CAPTURE: no LA support wired for this " + "generation yet"); + return 1; + } + logger->info("DEVOURER_LA_CAPTURE='{}' — one-shot capture after RX is " + "live (+{} ms settle) -> {}", g_la_spec, g_la_delay_ms, + g_la_out); + pcie_la_thread = std::thread([runner, la_params]() { + for (int w = 0; w < 10000 && !g_devourer_should_stop && g_rx_count == 0; + w += 50) + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + for (uint32_t s = 0; s < g_la_delay_ms && !g_devourer_should_stop; + s += 50) + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + if (g_devourer_should_stop) + return; + run_la_capture(runner, la_params); + }); + } try { dev->Init(packetProcessor, SelectedChannel{.Channel = static_cast(pch), @@ -652,8 +882,12 @@ int main() { .ChannelWidth = pwidth}); } catch (const std::exception &e) { logger->error("PCIe bring-up failed: {}", e.what()); + if (pcie_la_thread.joinable()) + pcie_la_thread.join(); return 1; } + if (pcie_la_thread.joinable()) + pcie_la_thread.join(); dev->Stop(); return 0; } @@ -1244,6 +1478,41 @@ int main() { return 0; } + /* DEVOURER_LA_CAPTURE: one-shot LA-mode IQ capture from a worker thread — + * waits for RX to be live (first frame, 10 s cap), then the settle delay, + * then runs the blocking arm → poll → readback sequence once. Register + * traffic shares libusb with the RX bulk loop like the energy/thermal + * pollers. */ + std::thread la_thread; + if (g_la_spec && *g_la_spec) { + devourer::LaParams la_params; + la_params.max_samples = g_la_max; + if (!parse_la_spec(g_la_spec, la_params)) { + logger->error("DEVOURER_LA_CAPTURE: bad spec '{}'", g_la_spec); + return 1; + } + auto runner = la_runner_for(rtlDevice.get()); + if (!runner) { + logger->error("DEVOURER_LA_CAPTURE: no LA support wired for this " + "generation yet"); + return 1; + } + logger->info("DEVOURER_LA_CAPTURE='{}' — one-shot capture after RX is " + "live (+{} ms settle) -> {}", g_la_spec, g_la_delay_ms, + g_la_out); + la_thread = std::thread([runner, la_params]() { + for (int w = 0; w < 10000 && !g_devourer_should_stop && g_rx_count == 0; + w += 50) + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + for (uint32_t s = 0; s < g_la_delay_ms && !g_devourer_should_stop; + s += 50) + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + if (g_devourer_should_stop) + return; + run_la_capture(runner, la_params); + }); + } + rtlDevice->Init(packetProcessor, SelectedChannel{ .Channel = static_cast(channel), .ChannelOffset = ch_offset, @@ -1254,6 +1523,8 @@ int main() { energy_emitter_stop = true; if (energy_emitter.joinable()) energy_emitter.join(); + if (la_thread.joinable()) + la_thread.join(); /* Clean chip de-init before dropping the interface (card-disable PWR_SEQ), so * the adapter re-enumerates instead of hanging its USB core. */ diff --git a/src/LaCapture.cpp b/src/LaCapture.cpp new file mode 100644 index 0000000..ee50dfd --- /dev/null +++ b/src/LaCapture.cpp @@ -0,0 +1,340 @@ +#include "LaCapture.h" + +#include +#include + +namespace devourer { + +namespace { +constexpr uint16_t kRegSysCfg = 0x00F0; /* liveness canary (BbDbgportReader) */ + +/* MAC-side LA block — identical across families + * (phydm_la_set_mac_iq_dump / phydm_la_get_tx_pkt_buf). */ +constexpr uint16_t kRegLaCtrl = 0x07C0; /* [0]=en [1]/[2]=poll [5]=manual + * [7:6]=mac sig [14:8]=trig time + * [30:16]=finish [31]=wrap */ +constexpr uint16_t kRegLaMacMask = 0x07C4; +constexpr uint16_t kRegLaMacSig = 0x07C8; +constexpr uint16_t kRegLaConf = 0x07CC; /* trig-time unit; [30]=full buff */ +constexpr uint16_t kRegTxffPage = 0x0140; /* page = 0x780 + (addr>>12) */ +constexpr uint16_t kRegTxffDbgEn = 0x0106; /* 0x69 = TXFF debug access */ +constexpr uint16_t kTxffWindow = 0x8000; /* 4 KB readback window */ +constexpr uint16_t kRegMacDbgEn = 0x00F4; /* [16] MAC dbg port enable */ +constexpr uint16_t kRegMacDbgSel = 0x0038; /* [23:16] MAC dbg port */ + +/* 11AC dialect (phydm_la_set_bb, phydm_debug.c dbg-port routing). */ +constexpr uint16_t kAcLaEngine = 0x095C; /* [4:0]=trig bit [7:5]=rate + * [11:8]=dma [23]=la clk [31]=edge */ +constexpr uint16_t kAcRptUpdate = 0x08B4; /* [7] update rpt every pkt */ +constexpr uint16_t kAcDbgPortSel = 0x08FC; /* selector (dword) */ +constexpr uint16_t kAcDbgPortHdr = 0x08F8; /* [25:22] header mux */ +constexpr uint16_t kAcDbgPortClk = 0x198C; /* [2:0] dbg-port clock (11AC-2) */ + +/* JGR3 dialect. */ +constexpr uint16_t kJ3LaEngine = 0x1CE4; /* [5:0]=dma [7:6]=mac-phy timing + * [10:8]=rate [17:13]=trig bit + * [26]=edge [27],[31:28]=adv AND */ +constexpr uint16_t kJ3LaOn = 0x1CF4; /* [23] LA mode on */ +constexpr uint16_t kJ3RptUpdate = 0x1EB4; /* [23] update rpt every pkt */ +constexpr uint16_t kJ3DbgPortSel = 0x1C3C; /* [19:8] selector */ +constexpr uint16_t kJ3AdvAnd123 = 0x1CE8; /* adv-trigger AND1..3 (reset 0) */ +constexpr uint16_t kJ3AdvAnd4 = 0x1CF0; /* adv-trigger AND4 mask */ +} // namespace + +LaCapture::LaCapture(RtlAdapter device, Logger_t logger, LaRegs regs) + : _device{device}, _logger{logger}, _regs{regs} {} + +bool LaCapture::is_chip_alive() { + uint32_t v = _device.rtw_read32(kRegSysCfg); + if (v == 0 || v == 0xFFFFFFFFu) { + _logger->error("LaCapture: SYS_CFG read 0x{:08x} — chip wedged. " + "Recovery: libusb_reset_device, usbreset, power-cycle.", + v); + return false; + } + return true; +} + +void LaCapture::set_reg(uint16_t addr, uint32_t mask, uint32_t val) { + _device.phy_set_bb_reg(addr, mask, val); +} + +void LaCapture::snapshot() { + _saved.clear(); + auto save = [&](uint16_t addr) { + _saved.emplace_back(addr, _device.rtw_read32(addr)); + }; + save(kRegLaCtrl); + save(kRegLaMacMask); + save(kRegLaMacSig); + save(kRegLaConf); + save(kRegTxffPage); + save(kRegMacDbgEn); + save(kRegMacDbgSel); + if (_regs.dialect == LaBbDialect::Ac11) { + save(kAcLaEngine); + save(kAcRptUpdate); + save(kAcDbgPortSel); + save(kAcDbgPortHdr); + if (_regs.dbgport_clk) + save(kAcDbgPortClk); + } else { + save(kJ3LaEngine); + save(kJ3LaOn); + save(kJ3RptUpdate); + save(kJ3DbgPortSel); + save(kJ3AdvAnd123); + save(kJ3AdvAnd4); + } +} + +void LaCapture::restore() { + /* Stop the LA block first so nothing samples while BB regs move, then + * put every touched register back (reverse order). 0x0106 goes back to + * 0 explicitly — the vendor flow leaks it set. */ + _device.rtw_write8(kRegLaCtrl, 0); + _device.rtw_write8(kRegTxffDbgEn, 0); + for (auto it = _saved.rbegin(); it != _saved.rend(); ++it) + _device.rtw_write32(it->first, it->second); +} + +void LaCapture::setup_trigger_time(const LaParams &p) { + /* phydm_la_set_mac_trigger_time: pick unit u so count < 128. */ + uint32_t unit = 0; + while (unit < 6 && (p.trigger_time_us >> unit) >= 128) + unit++; + uint32_t count = (p.trigger_time_us >> unit) & 0x7f; + set_reg(kRegLaConf, 0x7u << _regs.trig_time_unit_lsb, unit); + set_reg(kRegLaCtrl, 0x7f00u, count); +} + +void LaCapture::setup_bb(const LaParams &p) { + if (_regs.dialect == LaBbDialect::Ac11) { + set_reg(kAcDbgPortHdr, 0x3c00000u, p.hdr_sel); + set_reg(kAcRptUpdate, 1u << 7, 1); /* update rpt every pkt */ + set_reg(kAcLaEngine, 0xf00u, p.dma_type); + set_reg(kAcLaEngine, 1u << 31, p.edge); + set_reg(kAcLaEngine, 0xe0u, p.smp_rate); + if (_regs.needs_la_clk) /* 8821C cut B+ (caller skips cut A) */ + set_reg(kAcLaEngine, 1u << 23, 1); + } else { + set_reg(kJ3RptUpdate, 1u << 23, 1); /* update rpt every pkt */ + set_reg(kJ3LaEngine, 0xc0u, 0); /* MAC-PHY timing */ + set_reg(kJ3LaOn, 1u << 23, 1); /* LA mode on */ + set_reg(kJ3LaEngine, 0x3fu, p.dma_type); + set_reg(kJ3LaEngine, 1u << 26, p.edge); + set_reg(kJ3LaEngine, 0x700u, p.smp_rate); + /* Advanced AND-gate trigger at pass-through defaults + * (phydm_la_bb_adv_trig_setting_jgr3, adv table zeroed). */ + set_reg(kJ3LaEngine, 1u << 27, 0); + set_reg(kJ3LaEngine, 0xf0000000u, 0); + set_reg(kJ3AdvAnd123, 1u << 5, 0); + set_reg(kJ3AdvAnd123, 0x3c0u, 0); + set_reg(kJ3AdvAnd123, 1u << 15, 0); + set_reg(kJ3AdvAnd123, 0xf0000u, 0); + set_reg(kJ3AdvAnd123, 1u << 25, 0); + set_reg(kJ3AdvAnd4, 0xffffffffu, 0); + set_reg(kJ3AdvAnd123, 1u << 26, 0); + } +} + +void LaCapture::setup_dbg_port(const LaParams &p) { + /* phydm_set_bb_dbg_port(DBGPORT_PRI_3) + the trig-sel bit. */ + if (_regs.dialect == LaBbDialect::Ac11) { + if (_regs.dbgport_clk) + set_reg(kAcDbgPortClk, 0x7u, 0x7); + _device.rtw_write32(kAcDbgPortSel, p.dbg_port); + } else { + set_reg(kJ3DbgPortSel, 0xfff00u, p.dbg_port); + } + uint32_t trig_sel = + (p.trig_mode == LaTrigMode::MacDump) ? 0 : p.trig_sel; + if (_regs.dialect == LaBbDialect::Ac11) + set_reg(kAcLaEngine, 0x1fu, trig_sel); + else + set_reg(kJ3LaEngine, 0x3e000u, trig_sel); +} + +void LaCapture::setup_mac(const LaParams &p) { + /* phydm_la_set_mac_iq_dump. */ + _device.rtw_write8(kRegLaCtrl, 0); + set_reg(kRegLaCtrl, 1u << 0, 1); /* LA block on */ + + if (p.trig_mode == LaTrigMode::MacDump) { + set_reg(kRegLaCtrl, 1u << 2, 1); /* poll bit, MAC mode */ + set_reg(kRegLaCtrl, 0x18u, p.edge); + set_reg(kRegMacDbgEn, 1u << 16, 1); + set_reg(kRegMacDbgSel, 0xff0000u, p.dbg_port); + _device.rtw_write32(kRegLaMacMask, 0xffffffffu); + _device.rtw_write32(kRegLaMacSig, 0); + } else { + if (p.trig_mode == LaTrigMode::AdcMacTrig) { + set_reg(kRegLaCtrl, 1u << 3, 1); /* MAC-event trigger source */ + set_reg(kRegLaCtrl, 0xc0u, static_cast(p.mac_sig)); + } + set_reg(kRegLaCtrl, 1u << 1, 1); /* poll bit, BB-ADC mode */ + if (p.trig_mode == LaTrigMode::AdcMacTrig && + p.mac_sig == LaMacSig::Manual) { + /* Fire the register trigger (0x7c0[5] 0->1) only after the dump + * enable above is armed — the vendor sets it earlier in the same + * function, but hardware-bisected here: an edge before the poll + * bit arms is consumed and the capture never completes. */ + set_reg(kRegLaCtrl, 1u << 5, 0); + set_reg(kRegLaCtrl, 1u << 5, 1); + } + } +} + +bool LaCapture::poll(const LaParams &p) { + const uint8_t polling_bit = + (p.trig_mode == LaTrigMode::MacDump) ? (1u << 2) : (1u << 1); + for (uint32_t i = 0; i < p.poll_tries; i++) { + uint8_t v = _device.rtw_read8(kRegLaCtrl); + _logger->debug("LaCapture: poll[{}] 0x7c0[7:0]=0x{:02x} (wait bit 0x{:02x} " + "clear)", i, v, polling_bit); + if (!(v & polling_bit)) { + _logger->debug("LaCapture: capture complete after {} polls", i); + return true; + } + std::this_thread::sleep_for(std::chrono::milliseconds(p.poll_ms)); + } + return false; +} + +void LaCapture::readback(const LaParams &p, LaResult &r) { + /* phydm_la_get_tx_pkt_buf. */ + uint32_t buf_start = _regs.buf_start; + const uint32_t buf_end = _regs.buf_end; + if (p.buff_all && _regs.full_buff_capable) + buf_start = buf_end - 2 * (buf_end - buf_start); + + _device.rtw_write8(kRegTxffDbgEn, 0x69); + const uint32_t v = _device.rtw_read32(kRegLaCtrl); + r.round_up = (v >> 31) & 1; + r.finish_addr = (v & 0x7FFF0000u) >> 16; /* unit: 8 bytes */ + + uint32_t addr; /* byte address of the oldest sample */ + uint32_t count; /* samples to read */ + const uint32_t window_samples = (buf_end - buf_start) >> 3; + if (r.round_up) { + addr = (r.finish_addr + 2) << 3; /* vendor: "+1 or +2 ??" */ + count = window_samples; + } else { + addr = buf_start; + const uint32_t start8 = buf_start >> 3; + count = r.finish_addr > start8 ? r.finish_addr - start8 + : start8 - r.finish_addr; + } + if (count > window_samples) + count = window_samples; + if (p.max_samples && count > p.max_samples) { + /* Keep the NEWEST samples: the trigger event (and the frame that + * fired it) sits at the finish end of the ring, so a cap that kept + * the oldest end would drop exactly the content of interest. */ + uint32_t skip = count - p.max_samples; + addr += skip * 8; + while (addr >= buf_end) + addr -= (buf_end - buf_start); + count = p.max_samples; + } + + _logger->debug("LaCapture: readback {} samples from 0x{:05x} (finish=0x{:04x}" + " wrap={})", count, addr, r.finish_addr, r.round_up); + + r.samples.reserve(count); + uint32_t page = 0xFFFFFFFFu; + for (uint32_t i = 0; i < count; i++) { + if (addr >= buf_end) + addr = buf_start; /* ring */ + const uint32_t pg = addr >> 12; + if (pg != page) { + page = pg; + _device.rtw_write16(kRegTxffPage, static_cast(0x780 + pg)); + } + const uint16_t off = static_cast(kTxffWindow + (addr & 0xfff)); + const uint32_t data_l = _device.rtw_read32(off); + const uint32_t data_h = _device.rtw_read32(off + 4); + r.samples.push_back((static_cast(data_h) << 32) | data_l); + addr += 8; + } +} + +LaResult LaCapture::run(const LaParams &p) { + LaResult r; + if (_wedged) { + r.wedged = true; + return r; + } + + snapshot(); + if (p.buff_all && _regs.full_buff_capable) + set_reg(kRegLaConf, 1u << 30, 1); + else if (_regs.full_buff_capable) + set_reg(kRegLaConf, 1u << 30, 0); + + setup_trigger_time(p); + setup_bb(p); + setup_dbg_port(p); + setup_mac(p); + + if (!is_chip_alive()) { + _wedged = true; + r.wedged = true; + restore(); + return r; + } + + /* Post-arm register dump — the LA state the trigger sees. */ + if (_regs.dialect == LaBbDialect::Ac11) + _logger->debug("LaCapture: armed 0x7c0=0x{:08x} 0x7cc=0x{:08x} " + "0x95c=0x{:08x} 0x8b4=0x{:08x} 0x8fc=0x{:08x} " + "0x8f8=0x{:08x} 0x198c=0x{:08x}", + _device.rtw_read32(kRegLaCtrl), + _device.rtw_read32(kRegLaConf), + _device.rtw_read32(kAcLaEngine), + _device.rtw_read32(kAcRptUpdate), + _device.rtw_read32(kAcDbgPortSel), + _device.rtw_read32(kAcDbgPortHdr), + _device.rtw_read32(kAcDbgPortClk)); + else + _logger->debug("LaCapture: armed 0x7c0=0x{:08x} 0x7cc=0x{:08x} " + "0x1ce4=0x{:08x} 0x1cf4=0x{:08x} 0x1eb4=0x{:08x} " + "0x1c3c=0x{:08x}", + _device.rtw_read32(kRegLaCtrl), + _device.rtw_read32(kRegLaConf), + _device.rtw_read32(kJ3LaEngine), + _device.rtw_read32(kJ3LaOn), + _device.rtw_read32(kJ3RptUpdate), + _device.rtw_read32(kJ3DbgPortSel)); + + /* The arm sequence set 0x7c0[0]=1 (+poll bit). A die without the LA + * block reads the whole register back as zero — everything after would + * be a false-positive read of stale TXFF content. */ + if ((_device.rtw_read32(kRegLaCtrl) & 0x3u) == 0) { + r.no_la_block = true; + _logger->warn("LaCapture: 0x7c0 did not latch the arm bits — no LA " + "capture block on this die"); + restore(); + return r; + } + + if (poll(p)) { + readback(p, r); + r.ok = !r.samples.empty(); + } else { + r.poll_timeout = true; + _logger->warn("LaCapture: polling timeout after {} x {} ms — trigger " + "never fired or no LA block on this die", + p.poll_tries, p.poll_ms); + } + + restore(); + if (!is_chip_alive()) { + _wedged = true; + r.wedged = true; + r.ok = false; + } + return r; +} + +} // namespace devourer diff --git a/src/LaCapture.h b/src/LaCapture.h new file mode 100644 index 0000000..77ca5a2 --- /dev/null +++ b/src/LaCapture.h @@ -0,0 +1,180 @@ +/* LA-mode (phydm "logic analyzer" / ADC-sampling) IQ capture — the debug + * escape hatch that DMAs a raw baseband sample bus into the top of the TX + * packet buffer, host-readable through the 0x0140-page + 0x8000 window. + * An offline FFT of a captured L-LTF is the full per-tone channel estimate + * H(k), so this turns a dongle into a one-shot per-subcarrier channel + * sounder (docs/la-capture.md; offline tools: tools/la_decode.py, + * tools/la_csi.py). + * + * Ported from phydm_adc_sampling.c (vendored: reference/rtl88x2bu for the + * 11AC dialect, reference/rtl88x2cu + rtl88x2eu for JGR3); the dbg-port + * routing is phydm_debug.c (phydm_set_bb_dbg_port / + * phydm_release_bb_dbg_port). The MAC half (trigger time, 0x7c0 capture + * control, poll, TX-buffer readback) is identical across families; the BB + * half has exactly two register dialects, so one implementation serves all + * generations — LaRegs carries the per-chip parameters, mirroring + * NhmReader's NhmRegs pattern. + * + * SILICON SUPPORT (PHYDM_IC_SUPPORT_LA_MODE, phydm_pre_define.h): 8814A, + * 8822B, 8821C (cut B+ for the LA clock), 8822C, 8822E. The 8812A and + * 8821A are NOT in the macro and have no buffer-geometry case — on those + * dies la_regs_8812a_experimental() is a probe (expect a poll timeout). + * + * BRICK RISK — same class as BbDbgportReader: this pokes the BB dbg-port + * mux (0x8fc / 0x1c3c) and LA engine registers while the demod may be + * live. All touched registers are snapshotted at run() entry and restored + * on every exit path, and chip liveness (SYS_CFG readable) is checked + * after each phase; a dead read latches _wedged and the instance refuses + * further register writes. Recovery ladder if RX stalls after a capture: + * 1. libusb_reset_device (set DEVOURER_SKIP_RESET=0 on next launch) + * 2. USB port-level usbreset (tests/regress.py style) + * 3. power-cycle / replug + * Treat first runs on a new chip as destructive until proven otherwise. + * + * THREADING / TX CONTRACT: single-control-thread like every control-plane + * entry point. run() blocks for up to poll_ms*poll_tries + the readback + * (two vendor-control reads per 8-byte sample: 16 K samples ~ tens of + * seconds on USB2). The capture lands in the TOP of the TX packet buffer + * (above the pages the TX path uses), but do not run a capture concurrent + * with heavy TX — arm first, keep TX quiesced until readback completes. + */ + +#ifndef DEVOURER_LA_CAPTURE_H +#define DEVOURER_LA_CAPTURE_H + +#include +#include + +#include "RtlAdapter.h" +#include "logger.h" + +namespace devourer { + +enum class LaBbDialect : uint8_t { + Ac11, /* Jaguar1/2: 0x95c engine, 0x8fc dbg-port mux */ + Jgr3, /* Jaguar3: 0x1ce4/0x1cf4 engine, 0x1c3c dbg-port mux */ +}; + +/* Per-chip parameters (buffer geometry per phydm_la_set_buff_mode; HALF + * mode window = [buf_end - size, buf_end)). */ +struct LaRegs { + LaBbDialect dialect; + uint32_t buf_start; /* HALF-mode window start (byte addr in TXFF) */ + uint32_t buf_end; /* window end (exclusive) */ + bool dbgport_clk; /* 11AC-2 dbg-port clock gate 0x198c[2:0] */ + bool needs_la_clk; /* 8821C cut B+: LA clock 0x95c[23] */ + bool full_buff_capable;/* 0x7cc[30] doubles the window (8821C/8822C/E) */ + uint8_t trig_time_unit_lsb; /* 0x7cc trigger-time unit field LSB: 18 + * (11AC / Jaguar2) or 16 (JGR3) */ +}; + +inline LaRegs la_regs_8814a() { + return LaRegs{LaBbDialect::Ac11, 0x30000, 0x40000, true, false, false, 18}; +} +inline LaRegs la_regs_8822b() { + return LaRegs{LaBbDialect::Ac11, 0x20000, 0x40000, true, false, false, 18}; +} +inline LaRegs la_regs_8821c() { + /* needs_la_clk: 0x95c[23], skipped by the caller on cut A (vendor + * phydm_la_clk_en does the same). */ + return LaRegs{LaBbDialect::Ac11, 0x8000, 0x10000, true, true, true, 18}; +} +inline LaRegs la_regs_jgr3() { /* 8822C + 8822E */ + return LaRegs{LaBbDialect::Jgr3, 0x20000, 0x40000, false, false, true, 16}; +} +/* Probe map for dies the vendor macro excludes (8812A/8821A): 8814A + * geometry, no dbg-port clock gate (they are 11AC-1). A poll timeout here + * is the expected "no LA block" answer, not a failure of the plumbing. */ +inline LaRegs la_regs_8812a_experimental() { + return LaRegs{LaBbDialect::Ac11, 0x30000, 0x40000, false, false, false, 18}; +} + +/* Vendor rt_adcsmp_trig_sel, minus the RF0/RF1 chain taps (reachable via + * hdr_sel = 9/8 on 11AC if ever needed). */ +enum class LaTrigMode : uint8_t { + BbTrig = 0, /* trigger on a BB dbg-port bit (trig_sel picks the bit) */ + AdcMacTrig = 1, /* ADC dump triggered by a MAC event (mac_sig) */ + MacDump = 4, /* pure MAC dbg-port dump (not an IQ capture) */ +}; + +enum class LaMacSig : uint8_t { + CrcOk = 0, /* RX frame FCS pass */ + CrcFail = 1, /* RX frame FCS fail */ + Cca = 2, /* CCA assertion */ + Manual = 3, /* immediate register trigger (0x7c0[5]) */ +}; + +struct LaParams { + LaTrigMode trig_mode = LaTrigMode::AdcMacTrig; + LaMacSig mac_sig = LaMacSig::Manual; + /* 0=80M 1=40M 2=20M 3=10M 4=5M 5=2.5M 6=1.25M 7=160M */ + uint8_t smp_rate = 2; + /* 11AC 0x95c[11:8] / JGR3 0x1ce4[5:0] — selects the sampled bus. */ + uint8_t dma_type = 0; + /* BB dbg-port mux value (11AC: full 0x8fc dword; JGR3: 0x1c3c[19:8]). */ + uint32_t dbg_port = 0; + /* 11AC dbg-port header mux 0x8f8[25:22] (0 = ofdm_dbg[31:0]). */ + uint8_t hdr_sel = 0; + /* BbTrig: which dbg-port bit arms the trigger. */ + uint8_t trig_sel = 0; + uint8_t edge = 0; /* 0 = posedge, 1 = negedge */ + /* Post-trigger sampling time; the rest of the ring is pre-trigger. */ + uint32_t trigger_time_us = 100; + bool buff_all = false; /* double the window (full_buff_capable only) */ + uint32_t poll_ms = 100; + uint32_t poll_tries = 20; + /* Cap readback (8-byte samples read, oldest-first); 0 = whole window. */ + uint32_t max_samples = 0; +}; + +struct LaResult { + bool ok = false; + bool wedged = false; + bool poll_timeout = false; + /* 0x7c0 read back all-zero after arm: the LA capture block is not + * implemented on this die (observed on 8812A — vendor's support macro + * excludes it). Distinct from poll_timeout (block exists, no trigger). */ + bool no_la_block = false; + bool round_up = false; /* ring wrapped (capture filled the window) */ + uint32_t finish_addr = 0; /* 0x7c0[30:16], unit 8 bytes */ + /* Time-ordered (oldest first, wrap unrolled); each entry is one 8-byte + * LA word, (data_h << 32) | data_l — same order the vendor's + * "%08x%08x" dump prints. Packing of I/Q inside the word is dma_type- + * and dialect-dependent (tools/la_decode.py). */ + std::vector samples; +}; + +class LaCapture { + public: + LaCapture(RtlAdapter device, Logger_t logger, LaRegs regs); + + /* Full one-shot sequence: arm → (trigger) → poll → readback → restore. + * Safe to call repeatedly; after a wedge every call returns immediately + * with wedged=true. */ + LaResult run(const LaParams &p); + + bool is_wedged() const { return _wedged; } + + private: + void set_reg(uint16_t addr, uint32_t mask, uint32_t val); + void setup_trigger_time(const LaParams &p); + void setup_bb(const LaParams &p); + void setup_dbg_port(const LaParams &p); + void setup_mac(const LaParams &p); + bool poll(const LaParams &p); + void readback(const LaParams &p, LaResult &r); + void snapshot(); + void restore(); + bool is_chip_alive(); + + RtlAdapter _device; + Logger_t _logger; + LaRegs _regs; + bool _wedged = false; + /* run()-scoped snapshot of every register the sequence touches. */ + std::vector> _saved; +}; + +} // namespace devourer + +#endif /* DEVOURER_LA_CAPTURE_H */ diff --git a/src/jaguar1/BbDbgportReader.h b/src/jaguar1/BbDbgportReader.h index aa9c6ff..225a888 100644 --- a/src/jaguar1/BbDbgportReader.h +++ b/src/jaguar1/BbDbgportReader.h @@ -1,21 +1,19 @@ /* BB-dbgport reader — exploration framework for the Realtek "Jaguar" PHY * debug port. * - * STATUS: research dead-end as shipped. The transport (write u32 selector - * to 0x8FC, read u32 result from 0xFA0 via libusb vendor control) works - * and is wrapped here with the canonical save/restore pattern from - * upstream's only in-tree user (hal/rtl8814a/rtl8814a_phycfg.c:460-545, - * `phy_ADC_CLK_8814A`). What's MISSING is the selector that routes the - * post-FFT per-subcarrier IQ bus to 0xFA0 — that selector lives in - * Realtek's phydm sources, which are not vendored in this tree. Without - * it, sweeping selectors gives raw BB internals (clock-domain status, - * MAC_Active bits, BB busy flags) but not the IQ samples the precoder - * README ("Tier 4") wants for shape verification. - * - * What this file provides is the FRAMEWORK so a researcher with access to - * a phydm selector catalogue can plug in the right value at runtime and - * read it back — without recompiling and without rediscovering the - * save/restore + chip-alive plumbing. + * STATUS: single-word peek only. The transport (write u32 selector to + * 0x8FC, read u32 result from 0xFA0 via libusb vendor control) works and + * is wrapped here with the canonical save/restore pattern from upstream's + * only in-tree user (hal/rtl8814a/rtl8814a_phycfg.c:460-545, + * `phy_ADC_CLK_8814A`). The phydm selector catalogue IS vendored now + * (reference//hal/phydm/phydm_debug.c dbg-port routing + + * phydm_adc_sampling.c presets, e.g. 0x880/0xa80 = per-path ADC buses, + * 0x392 = EVM) — but a one-word-at-a-time peek of a streaming bus is + * still not an IQ capture. The productized consumer of the same dbg-port + * mux is src/LaCapture.h: LA-mode DMAs the selected bus into the TX + * packet buffer as a contiguous sample stream — that's the per-tone-CSI + * path (issue #150). This reader remains the lightweight probe for + * static/slow BB internals. * * BRICK RISK * Poking 0x8FC while RX is live can leave demod state machines spinning diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index 53c28bc..5f066c1 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -1780,3 +1780,22 @@ uint32_t RtlJaguarDevice::read_bb_dbgport(uint32_t selector) { bool RtlJaguarDevice::bb_dbgport_wedged() const { return _bb_dbgport && _bb_dbgport->is_wedged(); } + +devourer::LaResult RtlJaguarDevice::la_capture(const devourer::LaParams &p) { + if (!_la) { + const bool is8814 = _eepromManager->version_id.ICType == CHIP_8814A; + devourer::LaRegs regs; + if (is8814) { + regs = devourer::la_regs_8814a(); + } else { + /* 8812A/8821A are NOT in the vendor's PHYDM_IC_SUPPORT_LA_MODE and + * have no buffer-geometry case — this probe uses the 8814A map so a + * researcher can confirm the block's absence (expect la.timeout). */ + regs = devourer::la_regs_8812a_experimental(); + _logger->warn("la_capture: vendor-unsupported on this die (8812A/8821A " + "lack the LA block) — probe only, expect a poll timeout"); + } + _la = std::make_unique(_device, _logger, regs); + } + return _la->run(p); +} diff --git a/src/jaguar1/RtlJaguarDevice.h b/src/jaguar1/RtlJaguarDevice.h index 85f3e25..7824ba5 100644 --- a/src/jaguar1/RtlJaguarDevice.h +++ b/src/jaguar1/RtlJaguarDevice.h @@ -13,6 +13,7 @@ #include "logger.h" #include "BbDbgportReader.h" +#include "LaCapture.h" #include "HalModule.h" #include "IRtlDevice.h" #include "SelectedChannel.h" @@ -308,6 +309,14 @@ class RtlJaguarDevice : public IRtlDevice { uint32_t read_bb_dbgport(uint32_t selector); bool bb_dbgport_wedged() const; + /* Research helper: one-shot LA-mode (phydm logic-analyzer) IQ capture + * into the TX packet buffer — vendor-supported on the 8814A only + * (64 KB window at 0x30000); on 8812A/8821A this is a probe with the + * 8814A map (no LA block on those dies — expect poll_timeout). See + * LaCapture.h for the brick-risk caveats and the TX-quiesced contract. */ + devourer::LaResult la_capture(const devourer::LaParams &p); + bool la_capture_wedged() const { return _la && _la->is_wedged(); } + private: void StartWithMonitorMode(SelectedChannel selectedChannel); bool NetDevOpen(SelectedChannel selectedChannel); @@ -358,6 +367,8 @@ class RtlJaguarDevice : public IRtlDevice { std::atomic _therm_snap{0}; std::unique_ptr _bb_dbgport; + /* Lazy LA-mode capture helper (la_capture). */ + std::unique_ptr _la; }; /* Backwards-compatibility alias. External callers using the old name still diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index 0ef9a10..adeca7f 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -1011,6 +1011,26 @@ devourer::EfuseStability RtlJaguar2Device::ProbeEfuseStability(int reads) { return st; } +devourer::LaResult RtlJaguar2Device::la_capture(const devourer::LaParams &p) { + if (!_la) { + devourer::LaRegs regs = (_variant == jaguar2::ChipVariant::C8821C) + ? devourer::la_regs_8821c() + : devourer::la_regs_8822b(); + if (_variant == jaguar2::ChipVariant::C8821C && + _hal.chip_version().cut == 0) { + /* Vendor phydm_la_clk_en skips the LA clock on 8821C cut A; without + * it the engine may never complete — expect a poll timeout. */ + regs.needs_la_clk = false; + _logger->warn("la_capture: 8821C cut A — LA clock bit unavailable, " + "capture may time out"); + } + _la = std::make_unique(_device, _logger, regs); + } + /* Serialize against the calibration/thermal register windows. */ + std::lock_guard lk(_reg_mu); + return _la->run(p); +} + devourer::ThermalStatus RtlJaguar2Device::GetThermalStatus() { devourer::ThermalStatus t; if (!_brought_up) diff --git a/src/jaguar2/RtlJaguar2Device.h b/src/jaguar2/RtlJaguar2Device.h index 9071456..454a85a 100644 --- a/src/jaguar2/RtlJaguar2Device.h +++ b/src/jaguar2/RtlJaguar2Device.h @@ -19,6 +19,7 @@ #include "HalmacJaguar2Fw.h" #include "ChipVariant.h" #include "Jaguar2Calibration.h" +#include "LaCapture.h" /* RtlJaguar2Device is the orchestrator for the Realtek "Jaguar2" 802.11ac family * — RTL8822BU (chip 8822B, 2T2R, USB). It is the Jaguar2 sibling of @@ -186,6 +187,14 @@ class RtlJaguar2Device : public IRtlDevice { return _fw.boot_status(); } + /* Research helper: one-shot LA-mode (phydm logic-analyzer) IQ capture into + * the TX packet buffer — 8822B (128 KB window) / 8821C (32 KB, LA clock + * gated off on cut A like the vendor). Lazy-constructs the shared + * LaCapture with this variant's register map. Blocking; see LaCapture.h + * for the brick-risk caveats and the TX-quiesced contract. */ + devourer::LaResult la_capture(const devourer::LaParams &p); + bool la_capture_wedged() const { return _la && _la->is_wedged(); } + private: /* Golden-init replay (DEVOURER_REPLAY_WSEQ) — applied at the end of both * Init and InitWrite (see the definition for semantics). */ @@ -210,6 +219,8 @@ class RtlJaguar2Device : public IRtlDevice { int _xtal_cap = -1; /* current crystal-cap code (SetXtalCap) */ devourer::CfoTracker _cfo; /* closed-loop CFO tracker (DEVOURER_CFO_TRACK) */ jaguar2::HalJaguar2 _hal; + /* Lazy LA-mode capture helper (la_capture). */ + std::unique_ptr _la; jaguar2::HalmacJaguar2MacInit _macinit; jaguar2::HalmacJaguar2Fw _fw; SelectedChannel _channel{}; diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index 28328bc..1681b76 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -1072,6 +1072,16 @@ devourer::EfuseStability RtlJaguar3Device::ProbeEfuseStability(int reads) { return st; } +devourer::LaResult RtlJaguar3Device::la_capture(const devourer::LaParams &p) { + if (!_la) + _la = std::make_unique(_device, _logger, + devourer::la_regs_jgr3()); + /* Serialize against the coex runtime tick like every register-touching + * entry point. */ + std::lock_guard lk(_reg_mu); + return _la->run(p); +} + devourer::TxPowerState RtlJaguar3Device::GetTxPowerState() { devourer::TxPowerState s; s.valid = true; diff --git a/src/jaguar3/RtlJaguar3Device.h b/src/jaguar3/RtlJaguar3Device.h index 786f9a2..b968f0c 100644 --- a/src/jaguar3/RtlJaguar3Device.h +++ b/src/jaguar3/RtlJaguar3Device.h @@ -14,6 +14,7 @@ #include "SelectedChannel.h" #include "ChipVariant.h" #include "HalJaguar3.h" +#include "LaCapture.h" #include "RadioManagementJaguar3.h" /* RtlJaguar3Device is the orchestrator for the Realtek "Jaguar3" 802.11ac family @@ -186,6 +187,14 @@ class RtlJaguar3Device : public IRtlDevice { return _hal.fw_boot_status(); } + /* Research helper: one-shot LA-mode (phydm logic-analyzer) IQ capture into + * the TX packet buffer — JGR3 dialect (0x1ce4/0x1cf4 engine, 0x1c3c + * dbg-port mux), 128 KB window on both 8822C and 8822E. Serialized on + * _reg_mu against the coex runtime tick. Blocking; see LaCapture.h for + * the brick-risk caveats and the TX-quiesced contract. */ + devourer::LaResult la_capture(const devourer::LaParams &p); + bool la_capture_wedged() const { return _la && _la->is_wedged(); } + bool should_stop = false; private: @@ -204,6 +213,8 @@ class RtlJaguar3Device : public IRtlDevice { jaguar3::ChipVariant _variant; int _xtal_cap = -1; /* current crystal-cap code (SetXtalCap) */ jaguar3::HalJaguar3 _hal; + /* Lazy LA-mode capture helper (la_capture). */ + std::unique_ptr _la; jaguar3::RadioManagementJaguar3 _radioManagement; SelectedChannel _channel{}; Action_ParsedRadioPacket _packetProcessor = nullptr; diff --git a/tests/la_capture_smoke.sh b/tests/la_capture_smoke.sh new file mode 100755 index 0000000..98f18f2 --- /dev/null +++ b/tests/la_capture_smoke.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# la_capture_smoke.sh — per-adapter smoke test for LA-mode IQ capture +# (issue #150, src/LaCapture.h). Three cells: +# 1. manual — immediate register trigger, no RX dependency: la.capture +# ok, samples>0, buffer non-constant, chip alive after +# 2. crcok — MAC CRC-OK trigger fired by a canonical-SA TX partner +# (falls back to ambient traffic when no TX_PID given) +# 3. tx-conflict — capture, then TX 100 frames, then capture again: +# proves the TXFF window doesn't collide with live TX pages +# +# sudo tests/la_capture_smoke.sh [CH] [TX_PID] [DUT_VID] +# e.g. 8822BU DUT + 8812AU TX partner on ch 6: +# sudo tests/la_capture_smoke.sh 0xb82c 6 0x8812 +set -uo pipefail +cd "$(dirname "$0")/.." + +DUT_PID="${1:?usage: $0 [ch] [tx_pid] [dut_vid]}" +CH="${2:-6}" +TX_PID="${3:-}" +DUT_VID="${4:-0x0bda}" +OUT=/tmp/la-smoke +mkdir -p "$OUT" + +cleanup() { pkill -9 -x rxdemo 2>/dev/null; pkill -9 -x txdemo 2>/dev/null; } +trap cleanup EXIT +cleanup; sleep 1 + +PASS=0; FAIL=0 +verdict() { # verdict <0|1> + if [ "$2" = 1 ]; then echo " [PASS] $1"; PASS=$((PASS+1)); + else echo " [FAIL] $1"; FAIL=$((FAIL+1)); fi +} + +# check_capture +check_capture() { + local ev="$1" bin="$2" cell="$3" + python3 - "$ev" "$bin" "$cell" <<'EOF' +import json, struct, sys +ev_path, bin_path, cell = sys.argv[1], sys.argv[2], sys.argv[3] +cap = None; alive_after = False; wedged = False +t_cap = None +for line in open(ev_path, errors="replace"): + if not line.startswith('{"ev":"'): + continue + try: o = json.loads(line) + except Exception: continue + if o["ev"] == "la.capture": cap = o + elif o["ev"] == "la.wedged": wedged = True + elif o["ev"] in ("rx.pkt", "rx.frame", "rx.energy") and cap is not None: + alive_after = True # chip still delivering after the capture +ok = bool(cap and cap.get("ok") == 1 and cap.get("samples", 0) > 0) +nonconst = False +if ok: + try: + with open(bin_path, "rb") as f: + hdr = f.read(32) + assert hdr[:4] == b"DVLA", "bad magic" + n = struct.unpack_from(" 1 + except Exception as e: + print(f" [{cell}] bin check error: {e}") +print(f" [{cell}] capture={'ok' if ok else 'MISSING/FAIL'}" + f" samples={cap.get('samples') if cap else 0}" + f" wrap={cap.get('wrap') if cap else '-'}" + f" nonconst={nonconst} alive_after={alive_after} wedged={wedged}") +sys.exit(0 if (ok and nonconst and alive_after and not wedged) else 1) +EOF +} + +echo "############ 1. MANUAL TRIGGER ############" +sudo timeout 45 env DEVOURER_VID="$DUT_VID" DEVOURER_PID="$DUT_PID" \ + DEVOURER_CHANNEL="$CH" DEVOURER_LOG_LEVEL=info DEVOURER_RX_ENERGY_MS=1000 \ + DEVOURER_LA_CAPTURE=manual/20M/dma0/port:0x880 DEVOURER_LA_OUT="$OUT/manual.bin" \ + DEVOURER_LA_MAX=4096 \ + build/rxdemo >"$OUT/manual.jsonl" 2>"$OUT/manual.log" & +RXPID=$! +# give bring-up + settle + capture + readback time, then stop +sleep 35; sudo kill -INT "$RXPID" 2>/dev/null; wait "$RXPID" 2>/dev/null +check_capture "$OUT/manual.jsonl" "$OUT/manual.bin" manual +verdict "manual trigger" $((! $?)) +cleanup; sleep 2 + +echo "############ 2. CRC-OK TRIGGER ############" +if [ -n "$TX_PID" ]; then + sudo env DEVOURER_PID="$TX_PID" DEVOURER_CHANNEL="$CH" \ + DEVOURER_LOG_LEVEL=silent build/txdemo >/dev/null 2>&1 & + sleep 4 +fi +sudo timeout 60 env DEVOURER_VID="$DUT_VID" DEVOURER_PID="$DUT_PID" \ + DEVOURER_CHANNEL="$CH" DEVOURER_LOG_LEVEL=info DEVOURER_RX_ENERGY_MS=1000 \ + DEVOURER_LA_CAPTURE=crcok/20M/dma0/port:0x880/t100 DEVOURER_LA_OUT="$OUT/crcok.bin" \ + DEVOURER_LA_MAX=4096 \ + build/rxdemo >"$OUT/crcok.jsonl" 2>"$OUT/crcok.log" & +RXPID=$! +sleep 50; sudo kill -INT "$RXPID" 2>/dev/null; wait "$RXPID" 2>/dev/null +check_capture "$OUT/crcok.jsonl" "$OUT/crcok.bin" crcok +verdict "crcok trigger" $((! $?)) +cleanup; sleep 2 + +echo "############ 3. TX-CONFLICT (capture -> TX -> capture) ############" +# Run the DUT as TX for ~5 s (100+ frames through the low TXFF pages), +# then re-capture: the second capture must still succeed and the chip +# must still TX/RX. Uses the DUT itself for both. +sudo timeout 20 env DEVOURER_VID="$DUT_VID" DEVOURER_PID="$DUT_PID" \ + DEVOURER_CHANNEL="$CH" DEVOURER_LOG_LEVEL=silent DEVOURER_TX_GAP_US=20000 \ + build/txdemo >"$OUT/tx.jsonl" 2>/dev/null & +TXW=$! +sleep 15; sudo kill -INT "$TXW" 2>/dev/null; wait "$TXW" 2>/dev/null +cleanup; sleep 2 +sudo timeout 45 env DEVOURER_VID="$DUT_VID" DEVOURER_PID="$DUT_PID" \ + DEVOURER_CHANNEL="$CH" DEVOURER_LOG_LEVEL=info DEVOURER_RX_ENERGY_MS=1000 \ + DEVOURER_LA_CAPTURE=manual/20M/dma0/port:0x880 DEVOURER_LA_OUT="$OUT/posttx.bin" \ + DEVOURER_LA_MAX=4096 \ + build/rxdemo >"$OUT/posttx.jsonl" 2>"$OUT/posttx.log" & +RXPID=$! +sleep 35; sudo kill -INT "$RXPID" 2>/dev/null; wait "$RXPID" 2>/dev/null +check_capture "$OUT/posttx.jsonl" "$OUT/posttx.bin" posttx +verdict "post-TX capture" $((! $?)) +cleanup + +echo +echo "RESULT: $PASS pass, $FAIL fail (logs: $OUT)" +[ "$FAIL" = 0 ] diff --git a/tests/la_cw_score.sh b/tests/la_cw_score.sh new file mode 100755 index 0000000..8351455 --- /dev/null +++ b/tests/la_cw_score.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# la_cw_score.sh — establish the LA-capture IQ packing empirically (issue +# #150). B210 radiates a CW at a known offset from the DUT's channel +# center; the DUT takes one LA capture; tools/la_decode.py scores every +# candidate bit-layout — the correct one shows a single sharp FFT line at +# the expected offset. +# +# sudo tests/la_cw_score.sh [CH] [OFFSET_MHZ] [DUT_VID] +# sudo tests/la_cw_score.sh 0x012d 6 2 0x2357 # 8822BU (T3U) +set -uo pipefail +cd "$(dirname "$0")/.." + +DUT_PID="${1:?usage: $0 [ch] [offset_mhz] [dut_vid]}" +CH="${2:-6}" +OFF="${3:-2}" +DUT_VID="${4:-0x0bda}" +OUT=/tmp/la-cw +mkdir -p "$OUT" + +# sdr_interferer's cw mode bakes a +2.5 MHz baseband tone into the buffer — +# ask for (center + OFF - 2.5) so the on-air tone lands at center + OFF. +FREQ=$(python3 -c "print((2407+5*$CH if $CH<=14 else 5000+5*$CH) + $OFF - 2.5)") + +cleanup() { pkill -9 -x rxdemo 2>/dev/null; pkill -9 -f sdr_interferer 2>/dev/null; } +trap cleanup EXIT +cleanup; sleep 1 + +echo "== B210 CW at ${FREQ} MHz (ch $CH center + ${OFF} MHz) ==" +sudo python3 tests/sdr_interferer.py --freq "${FREQ}e6" --mode cw \ + --tx-gain 60 --secs 60 >"$OUT/cw.log" 2>&1 & +sleep 6 + +echo "== DUT capture (manual trigger, 20 Msps) ==" +sudo timeout 45 env DEVOURER_VID="$DUT_VID" DEVOURER_PID="$DUT_PID" \ + DEVOURER_CHANNEL="$CH" DEVOURER_LOG_LEVEL=info \ + DEVOURER_LA_CAPTURE=manual/20M/dma0/port:0x880 \ + DEVOURER_LA_OUT="$OUT/cw.bin" DEVOURER_LA_MAX=8192 \ + build/rxdemo >"$OUT/cw.jsonl" 2>"$OUT/rx.log" & +RXPID=$! +sleep 35; sudo kill -INT "$RXPID" 2>/dev/null; wait "$RXPID" 2>/dev/null +cleanup + +grep -F '"ev":"la.capture"' "$OUT/cw.jsonl" || { echo "NO CAPTURE"; exit 1; } +echo +echo "== layout scores (expected peak at +${OFF} MHz) ==" +python3 tools/la_decode.py score "$OUT/cw.bin" --offset-mhz "$OFF" diff --git a/tests/la_sdr_compare.py b/tests/la_sdr_compare.py new file mode 100644 index 0000000..bb0eec8 --- /dev/null +++ b/tests/la_sdr_compare.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""SDR cross-check: chip LA-capture H(k) vs a B210-derived estimate. + +Two subcommands: + + capture — record raw complex64 IQ from the B210 at 20 Msps on a Wi-Fi + channel (the same grid the chip's LA capture uses): + sudo python3 la_sdr_compare.py capture --channel 6 \ + --secs 0.5 --out /tmp/sdr.c64 + compare — run the SAME L-LTF pipeline (tools/la_csi.py) over both + captures and correlate per-tone |H(k)|: + python3 la_sdr_compare.py compare --sdr /tmp/sdr.c64 \ + --chip /tmp/la.bin [--min-corr 0.9] + +The SDR stream holds many frames; every detected L-LTF yields one +estimate and the per-tone MEDIAN |H| is the SDR-side answer (robust to +the odd foreign beacon on a quiet bench channel). The chip capture is a +one-shot crcok trigger, so it holds the one frame that fired it. + +Interpretation caveat (bench truth, MEASURED): the two receivers sit at +different antennas and see INDEPENDENT frequency-selective fading — +chip-vs-SDR |H(k)| correlation over the air came out ~0 while each side +is strongly self-consistent (chip-vs-chip 0.70 across captures of mixed +transmitters; SDR same-TX cluster 0.9). The `compare` subcommand remains +as the diagnostic that measured this; the DEFINITIVE cross-validation is +the notch protocol below. + +Notch protocol (txltf / check-notch): the B210 transmits repeated L-LTF +bursts with a chosen set of tones ZEROED — a deep deterministic spectral +feature that multipath cannot mimic or mask. The chip captures with a +CCA trigger and its H(k) must show those exact tones >= --min-notch dB +below the median of the untouched tones. Asymmetric notch sets also +catch tone-mapping / spectral-inversion errors end to end. + + sudo python3 la_sdr_compare.py txltf --channel 6 \ + --notch=-21,-20,-19,7,8,9 --secs 40 & + DEVOURER_LA_CAPTURE=cca/20M/dma0/port:0x880/t100 build/rxdemo ... + python3 la_sdr_compare.py check-notch --chip /tmp/la.bin \ + --notch=-21,-20,-19,7,8,9 +""" + +import argparse +import json +import os +import sys + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), + "..", "tools")) +import la_csi # noqa: E402 +import la_decode # noqa: E402 + + +def chan_to_freq(ch): + return (2407 + 5 * ch if ch <= 14 else 5000 + 5 * ch) * 1e6 + + +def do_capture(args): + import uhd + usrp = uhd.usrp.MultiUSRP(args.args) + rate = 20e6 + usrp.set_rx_rate(rate) + usrp.set_rx_freq(uhd.types.TuneRequest(chan_to_freq(args.channel))) + usrp.set_rx_gain(args.gain) + usrp.set_rx_antenna("RX2") + st_args = uhd.usrp.StreamArgs("fc32", "sc16") + st_args.channels = [0] + rx = usrp.get_rx_stream(st_args) + md = uhd.types.RXMetadata() + buf = np.zeros((1, rx.get_max_num_samps()), dtype=np.complex64) + nsamp = int(rate * args.secs) + out = np.empty(nsamp, dtype=np.complex64) + got = 0 + sc = uhd.types.StreamCMD(uhd.types.StreamMode.start_cont) + sc.stream_now = True + rx.issue_stream_cmd(sc) + while got < nsamp: + n = rx.recv(buf, md) + if md.error_code != uhd.types.RXMetadataErrorCode.none or n == 0: + continue + take = min(n, nsamp - got) + out[got:got + take] = buf[0, :take] + got += take + rx.issue_stream_cmd(uhd.types.StreamCMD(uhd.types.StreamMode.stop_cont)) + out.tofile(args.out) + print(f"wrote {nsamp} samples -> {args.out}", file=sys.stderr) + return 0 + + +def h_estimates(x, max_ltfs=40, chunk=65536): + """Every L-LTF found in the stream -> list of |H| arrays (52 tones).""" + nz = la_csi.LTF_SEQ != 0 + ests = [] + i = 0 + while i + 8192 < len(x) and len(ests) < max_ltfs: + seg = x[i:i + chunk] + start, cfo = la_csi.locate_lltf(seg) + if start is None: + i += chunk - 256 + continue + if start + 128 <= len(seg): + h = la_csi.estimate_h(seg, start, cfo) + mag = np.abs(h[nz]) + if mag.max() > 0: + ests.append(mag / np.median(mag)) # scale-free shape + i += start + 160 # skip past this LTF, keep scanning + return ests + + +def do_compare(args): + nz = la_csi.LTF_SEQ != 0 + tones = la_csi.TONES[nz] + + # Chip side: one-shot LA capture. + meta, words = la_decode.read_dump(args.chip) + xc = la_decode.LAYOUTS[args.layout][1](words) + xc = xc - np.mean(xc) + start, cfo = la_csi.locate_lltf(xc) + if start is None: + print("FAIL: no L-LTF in the chip capture", file=sys.stderr) + return 1 + hc = np.abs(la_csi.estimate_h(xc, start, cfo)[nz]) + hc = hc / np.median(hc) + + # SDR side: median shape over every detected LTF. + xs = np.fromfile(args.sdr, dtype=np.complex64) + xs = xs - np.mean(xs) + ests = h_estimates(xs) + if not ests: + print("FAIL: no L-LTF in the SDR capture", file=sys.stderr) + return 1 + hs = np.median(np.array(ests), axis=0) + + hc_db = 20 * np.log10(hc + 1e-9) + hs_db = 20 * np.log10(hs + 1e-9) + corr = float(np.corrcoef(hc_db, hs_db)[0, 1]) + rmse = float(np.sqrt(np.mean((hc_db - hs_db) ** 2))) + + for k, c, s in zip(tones, hc_db, hs_db): + print(json.dumps({"tone": int(k), "chip_db": round(float(c), 2), + "sdr_db": round(float(s), 2)})) + print(f"# chip LTF@{start}, SDR LTFs used: {len(ests)}", file=sys.stderr) + print(f"# |H(k)| dB correlation = {corr:.3f}, rmse = {rmse:.2f} dB", + file=sys.stderr) + ok = corr >= args.min_corr + print(f"# {'PASS' if ok else 'FAIL'} (threshold {args.min_corr})", + file=sys.stderr) + return 0 if ok else 1 + + +def parse_notch(s): + return [int(t) for t in s.split(",") if t] + + +def do_txltf(args): + """B210: repeated L-LTF bursts with the notched tones zeroed.""" + import uhd + notch = set(parse_notch(args.notch)) + f = la_csi.ltf_freq64().copy() + for k in notch: + f[k % 64] = 0 + ref = np.fft.ifft(f) + # The settling preamble IS the LTF: 10 repeated periods (~32 us). The + # RX AGC slews during the first few; any interior 128-sample window + # is a valid estimation window (the train is 64-periodic), and the + # locator naturally picks the cleanest one. (An L-STF preamble was + # tried and rejected: its 16-sample comb also repeats at lag 64 and + # its tone set (±4k) aliases straight into the estimate.) + burst = np.tile(ref, 10) + one = np.concatenate([burst, np.zeros(2048, dtype=complex)]) + one = args.amplitude * one / np.max(np.abs(one)) + # Long precomputed buffer (~40 bursts / 4.6 ms per send) — per-burst + # send calls underrun the B210 from python and nothing airs. + frame = np.tile(one, 40).astype(np.complex64) + usrp = uhd.usrp.MultiUSRP(args.args) + rate = 20e6 + usrp.set_tx_rate(rate) + usrp.set_tx_freq(uhd.types.TuneRequest(chan_to_freq(args.channel))) + usrp.set_tx_gain(args.gain) + st_args = uhd.usrp.StreamArgs("fc32", "sc16") + st_args.channels = [0] + tx = usrp.get_tx_stream(st_args) + md = uhd.types.TXMetadata() + md.start_of_burst = True + reps = int(args.secs * rate / len(frame)) + print(f"txltf: ch {args.channel}, notch {sorted(notch)}, " + f"{reps} bursts", file=sys.stderr) + for _ in range(reps): + tx.send(frame, md) + md.start_of_burst = False + md.end_of_burst = True + tx.send(np.zeros(0, dtype=np.complex64), md) + return 0 + + +def do_check_notch(args): + notch = parse_notch(args.notch) + nz = la_csi.LTF_SEQ != 0 + tones = la_csi.TONES[nz] + meta, words = la_decode.read_dump(args.chip) + x = la_decode.LAYOUTS[args.layout][1](words) + x = x - np.mean(x) + # Average |H| over every LTF burst in the ring (txltf repeats one + # burst every ~114 us, so an 8 K-sample capture holds several) — + # noise-limited notch depth improves with each burst averaged. + mags = [] + start0 = cfo0 = None + chunk = 2400 # one txltf burst period (~2288) + margin + for i in range(0, max(1, len(x) - 192), chunk - 300): + seg = x[i:i + chunk] + start, cfo = la_csi.locate_lltf(seg, min_metric=0.5) + if start is None or i + start + 128 > len(x): + continue + # txltf transmits an LTF *train* — refine CFO over every clean + # repetition (a 2-symbol estimate's few-kHz noise leaks ~-8 dB + # into the tones adjacent to a deep notch). + cfo_r = la_csi.refine_cfo(seg, start) + if cfo_r is not None: + cfo = cfo_r + if start0 is None: + start0, cfo0 = i + start, cfo + mags.append(np.abs(la_csi.estimate_h(seg, start, cfo)[nz])) + if not mags: + print("FAIL: no L-LTF in the chip capture", file=sys.stderr) + return 1 + start, cfo = start0, cfo0 + h_db = 20 * np.log10(np.mean(np.array(mags), axis=0) + 1e-9) + is_notch = np.isin(tones, notch) + # Depth vs the LOCAL neighbours (nearest clean tones on each side): + # genuine multipath ripple is smooth across adjacent tones (delay + # spread << symbol time) while the imposed TX notch is not — a global + # median reference conflates real channel dips with the notch. + depth = [] + for idx in np.where(is_notch)[0]: + neigh = [j for j in range(max(0, idx - 3), min(len(tones), idx + 4)) + if not is_notch[j]] + depth.append(float(np.mean(h_db[neigh])) - h_db[idx]) + depth = np.array(depth) + for k, d in zip(tones[is_notch], depth): + print(json.dumps({"tone": int(k), "notch_depth_db": round(float(d), 1)})) + ok = bool(np.all(depth >= args.min_notch)) + # Context: the clean tones' own ripple (real channel + noise). + clean_med = float(np.median(h_db[~is_notch])) + clean_min = float(np.min(h_db[~is_notch])) + print(f"# LTF@{start} cfo={cfo * 20e3:+.1f} kHz bursts={len(mags)} | " + f"notch depth min={depth.min():.1f} dB (need >= {args.min_notch}), " + f"clean-tone ripple={clean_med - clean_min:.1f} dB", file=sys.stderr) + print(f"# {'PASS' if ok else 'FAIL'}", file=sys.stderr) + return 0 if ok else 1 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + c = sub.add_parser("capture") + c.add_argument("--channel", type=int, default=6) + c.add_argument("--secs", type=float, default=0.5) + c.add_argument("--gain", type=float, default=50) + c.add_argument("--args", default="") + c.add_argument("--out", required=True) + m = sub.add_parser("compare") + m.add_argument("--sdr", required=True, help="complex64 file (capture)") + m.add_argument("--chip", required=True, help="DVLA dump") + m.add_argument("--layout", default=la_decode.DEFAULT_LAYOUT) + m.add_argument("--min-corr", type=float, default=0.9) + t = sub.add_parser("txltf") + t.add_argument("--channel", type=int, default=6) + t.add_argument("--notch", required=True, + help="comma-separated tone indices to zero, e.g. -21,-20,7") + t.add_argument("--secs", type=float, default=40) + t.add_argument("--gain", type=float, default=60) + t.add_argument("--amplitude", type=float, default=0.5) + t.add_argument("--args", default="") + n = sub.add_parser("check-notch") + n.add_argument("--chip", required=True) + n.add_argument("--notch", required=True) + n.add_argument("--layout", default=la_decode.DEFAULT_LAYOUT) + n.add_argument("--min-notch", type=float, default=10.0) + args = ap.parse_args() + sys.exit({"capture": do_capture, "compare": do_compare, + "txltf": do_txltf, "check-notch": do_check_notch}[args.cmd](args)) + + +if __name__ == "__main__": + main() diff --git a/tests/la_sdr_crosscheck.sh b/tests/la_sdr_crosscheck.sh new file mode 100755 index 0000000..f01b1bf --- /dev/null +++ b/tests/la_sdr_crosscheck.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# la_sdr_crosscheck.sh — issue #150: validate the chip's LA-derived +# per-tone H(k) against SDR ground truth via the NOTCH PROTOCOL. +# +# The B210 transmits an L-LTF train with a chosen asymmetric set of tones +# ZEROED — a deep deterministic spectral feature multipath cannot mimic. +# The DUT takes a CCA-triggered LA capture of that burst and its H(k) +# must show the notch at exactly those tones (tests/la_sdr_compare.py +# check-notch, local-neighbour depth). +# +# Why not |H(k)| correlation chip-vs-SDR (the original idea): measured +# ~0 on this bench — the two receivers' antennas see INDEPENDENT +# frequency-selective fading, while each side is strongly self-consistent. +# The `compare` subcommand survives as that diagnostic. +# +# Expected depth is chip-dependent: the 8822BU resolves >20 dB on every +# notched tone; the 8822CU (Jaguar3) has an ~8 dB adjacent-tone leakage +# floor (both RX paths, common LO) so single-tone edges bound at ~5-8 dB +# while notch centres reach 15-23 dB — pass it with MIN_NOTCH=5. +# +# sudo tests/la_sdr_crosscheck.sh [CH] [DUT_VID] [MIN_NOTCH] +# sudo tests/la_sdr_crosscheck.sh 0x012d 44 0x2357 10 # 8822BU +# sudo tests/la_sdr_crosscheck.sh 0xc812 44 0x0bda 5 # 8822CU (J3) +set -uo pipefail +cd "$(dirname "$0")/.." + +DUT_PID="${1:?usage: $0 [ch] [dut_vid] [min_notch]}" +CH="${2:-44}" +DUT_VID="${3:-0x0bda}" +MIN_NOTCH="${4:-10}" +NOTCH="-21,-20,-19,7,8,9" # asymmetric: catches tone-mapping/inversion bugs +OUT=/tmp/la-sdr +mkdir -p "$OUT" + +cleanup() { pkill -9 -x rxdemo 2>/dev/null; pkill -9 -f la_sdr_compare 2>/dev/null; } +trap cleanup EXIT +cleanup; sleep 1 + +echo "== B210 notched-LTF train on ch $CH (notch $NOTCH) ==" +sudo python3 tests/la_sdr_compare.py txltf --channel "$CH" \ + --notch="$NOTCH" --secs 60 --gain 76 >"$OUT/txltf.log" 2>&1 & +sleep 10 + +echo "== DUT LA capture (CCA trigger) ==" +sudo timeout 45 env DEVOURER_VID="$DUT_VID" DEVOURER_PID="$DUT_PID" \ + DEVOURER_CHANNEL="$CH" DEVOURER_LOG_LEVEL=info \ + DEVOURER_LA_CAPTURE=cca/20M/dma0/port:0x880/t200 \ + DEVOURER_LA_OUT="$OUT/chip.bin" DEVOURER_LA_MAX=8192 \ + build/rxdemo >"$OUT/chip.jsonl" 2>"$OUT/chip.log" & +RXPID=$! +sleep 35; sudo kill -INT "$RXPID" 2>/dev/null; wait "$RXPID" 2>/dev/null +cleanup + +grep -F '"ev":"la.capture"' "$OUT/chip.jsonl" || { echo "NO CHIP CAPTURE"; exit 1; } +echo +echo "== notch check (min depth ${MIN_NOTCH} dB) ==" +python3 tests/la_sdr_compare.py check-notch --chip "$OUT/chip.bin" \ + --notch="$NOTCH" --min-notch "$MIN_NOTCH" diff --git a/tools/la_csi.py b/tools/la_csi.py new file mode 100644 index 0000000..7662415 --- /dev/null +++ b/tools/la_csi.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Per-tone channel estimate H(k) from an LA-mode capture (issue #150). + +Locates an 802.11 L-LTF (legacy long training field) in the complex +baseband stream of a DVLA capture (tools/la_decode.py unpacking, default +layout qi12_l), corrects CFO from the two LTF repetitions, FFTs both +64-sample symbols, and divides by the known ±26-tone sequence — the full +per-subcarrier channel estimate no Jaguar chip exports through a normal +host path. + + la_csi.py cap.bin [--layout qi12_l] [--json out.jsonl] [--plot h.png] + la_csi.py --selftest # synthetic L-LTF through a 2-tap channel + +Requires a capture at 20 Msps of a 20 MHz channel (the standard L-LTF +sample grid): DEVOURER_LA_CAPTURE=crcok/20M/dma0/port:0x880 — the CRC-OK +trigger fires at frame *end*, and the wrapped ring holds the whole frame +including the preamble. + +Detection: 64-lag autocorrelation plateau (the LTF's two identical +periods), refined by cross-correlation against the reference symbol. +Output: one JSON line per tone {tone, mag, mag_db, phase_rad} plus a +summary. Selftest: 2-tap channel + AWGN, asserts |H(k)| correlation +against the analytic channel > 0.99 (exits nonzero on failure — wired as +the la_csi_math ctest). +""" + +import argparse +import json +import os +import sys + +try: + import numpy as np +except ImportError: + # ctest SKIP_RETURN_CODE — CI runners without numpy skip, not fail. + print("numpy not available — skipping", file=sys.stderr) + sys.exit(77) + +# L_{-26..-1}, 0 (DC), L_{+1..+26} — IEEE 802.11-2016 19.3.10.10. +LTF_SEQ = np.array( + [1, 1, -1, -1, 1, 1, -1, 1, -1, 1, 1, 1, 1, 1, 1, -1, -1, 1, 1, -1, + 1, -1, 1, 1, 1, 1, + 0, + 1, -1, -1, 1, 1, -1, 1, -1, 1, -1, -1, -1, -1, -1, 1, 1, -1, -1, 1, + -1, 1, -1, 1, 1, 1, 1], dtype=float) +TONES = np.arange(-26, 27) # tone indices matching LTF_SEQ (incl. DC) + + +def ltf_freq64(): + """The L-LTF mapped onto a 64-bin FFT grid (bin = tone mod 64).""" + f = np.zeros(64, dtype=complex) + for k, v in zip(TONES, LTF_SEQ): + f[k % 64] = v + return f + + +def ltf_ref_time(): + """One 64-sample time-domain LTF period at 20 Msps.""" + return np.fft.ifft(ltf_freq64()) + + +def locate_lltf(x, min_metric=0.6): + """Find the start of the first LTF 64-sample period. + + Returns (start_index, cfo_norm) or (None, 0). cfo_norm is the + frequency offset in cycles/sample estimated from the phase drift + between the two repetitions.""" + n = len(x) + if n < 192: + return None, 0.0 + # 64-lag autocorrelation plateau (two identical periods). + lag = 64 + c = x[:n - lag] * np.conj(x[lag:]) + p = np.abs(x[lag:]) ** 2 + win = 64 + ker = np.ones(win) + num = np.convolve(c, ker, "valid") + den = np.convolve(p, ker, "valid") + 1e-12 + metric = np.abs(num) / den + # Candidate plateau peaks, best first. + ref = ltf_ref_time() + best = None + for idx in np.argsort(metric)[::-1][:2000]: + if metric[idx] < min_metric: + break + # Refine with cross-correlation against the reference in a small + # neighbourhood (plateau peaks are timing-ambiguous by design). + lo = max(0, idx - 96) + hi = min(n - 128, idx + 96) + if hi <= lo: + continue + seg = x[lo:hi + 128] + cc = np.abs(np.correlate(seg, ref, "valid")) + k = int(np.argmax(cc)) + start = lo + k + if start + 128 > n: + continue + # Sanity: the two 64-blocks must actually repeat. + a, b = x[start:start + 64], x[start + 64:start + 128] + rep = np.abs(np.vdot(a, b)) / (np.linalg.norm(a) * np.linalg.norm(b) + + 1e-12) + if rep < 0.5: + continue + # Reject the L-STF: its 16-sample periodicity also repeats at lag + # 64 (and its tone comb aliases into an LTF-looking estimate). A + # true LTF has LOW 16-lag self-similarity. + blk = x[start:start + 128] + r16 = np.abs(np.vdot(blk[:-16], blk[16:])) / ( + np.linalg.norm(blk[:-16]) * np.linalg.norm(blk[16:]) + 1e-12) + if r16 > 0.8: + continue + best = (start, np.vdot(a, b)) + break + if best is None: + return None, 0.0 + start, acc = best + cfo_norm = np.angle(acc) / (2 * np.pi * 64) + return start, cfo_norm + + +def refine_cfo(x, start, max_periods=8, min_rep=0.5): + """Re-estimate CFO over as many consecutive 64-sample repetitions as + the signal provides (LTF-train captures carry ~10) — the 2-symbol + estimate's few-kHz noise leaks measurably into adjacent tones.""" + acc = 0.0 + 0.0j + used = 0 + for k in range(max_periods): + a = x[start + 64 * k:start + 64 * (k + 1)] + b = x[start + 64 * (k + 1):start + 64 * (k + 2)] + if len(b) < 64: + break + rep = np.abs(np.vdot(a, b)) / (np.linalg.norm(a) * np.linalg.norm(b) + + 1e-12) + if rep < min_rep: + break + acc += np.vdot(a, b) + used += 1 + if used == 0: + return None + return np.angle(acc) / (2 * np.pi * 64) + + +def estimate_h(x, start, cfo_norm, backoff=4): + """CFO-correct, FFT both LTF symbols, average, divide by the tones. + + The FFT window is pulled `backoff` samples INTO the guard interval: + the GI2 is a cyclic prefix of the same symbol, so the shift is a pure + linear phase across tones (harmless for |H|) while absorbing channel + delay spread that would otherwise leak inter-symbol energy into deep + nulls (measured on the notch-validation protocol).""" + start = max(0, start - backoff) + n = np.arange(start, start + 128) + seg = x[start:start + 128] * np.exp(-2j * np.pi * cfo_norm * n) + s1 = np.fft.fft(seg[:64]) + s2 = np.fft.fft(seg[64:128]) + avg = (s1 + s2) / 2 + h = np.zeros(len(TONES), dtype=complex) + for i, (k, v) in enumerate(zip(TONES, LTF_SEQ)): + if v != 0: + h[i] = avg[k % 64] / v + return h + + +def run(x, args): + start, cfo = locate_lltf(x) + if start is None: + print("no L-LTF found in capture", file=sys.stderr) + return 1 + h = estimate_h(x, start, cfo) + nz = LTF_SEQ != 0 + mag = np.abs(h[nz]) + print(f"# L-LTF at sample {start}, CFO {cfo * 20e6 / 1e3:+.1f} kHz, " + f"|H| mean {mag.mean():.1f} spread " + f"{20 * np.log10(mag.max() / max(mag.min(), 1e-9)):.1f} dB", + file=sys.stderr) + out = open(args.json, "w") if args.json else sys.stdout + for k, hv, seq in zip(TONES, h, LTF_SEQ): + if seq == 0: + continue + out.write(json.dumps({ + "tone": int(k), + "mag": float(np.abs(hv)), + "mag_db": float(20 * np.log10(np.abs(hv) + 1e-12)), + "phase_rad": float(np.angle(hv)), + }) + "\n") + if args.json: + out.close() + if args.plot: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + fig, (a1, a2) = plt.subplots(2, 1, figsize=(9, 6), sharex=True) + a1.stem(TONES[nz], 20 * np.log10(np.abs(h[nz]) + 1e-12)) + a1.set_ylabel("|H(k)| dB") + a1.grid(True, alpha=0.3) + a2.stem(TONES[nz], np.angle(h[nz])) + a2.set_ylabel("arg H(k) rad") + a2.set_xlabel("subcarrier") + a2.grid(True, alpha=0.3) + fig.savefig(args.plot, dpi=100, bbox_inches="tight") + print(f"wrote {args.plot}", file=sys.stderr) + return 0 + + +def selftest(): + """Synthetic L-LTF through a known 2-tap channel + AWGN.""" + rng = np.random.default_rng(7) + ref = ltf_ref_time() + # GI2 (32 samples) + two periods, embedded in noise padding. + ltf = np.concatenate([ref[-32:], ref, ref]) + x = np.concatenate([ + 0.05 * (rng.standard_normal(700) + 1j * rng.standard_normal(700)), + ltf, + 0.05 * (rng.standard_normal(700) + 1j * rng.standard_normal(700)), + ]) + taps = np.array([1.0, 0.55 * np.exp(1j * 1.1)]) + y = np.convolve(x, taps, "full")[:len(x)] + cfo_true = 45e3 / 20e6 # 45 kHz at 20 Msps + y = y * np.exp(2j * np.pi * cfo_true * np.arange(len(y))) + y += 0.01 * (rng.standard_normal(len(y)) + 1j * rng.standard_normal(len(y))) + + start, cfo = locate_lltf(y) + assert start is not None, "selftest: L-LTF not found" + cfo_err_khz = abs(cfo - cfo_true) * 20e3 + assert cfo_err_khz < 2.0, f"selftest: CFO error {cfo_err_khz:.2f} kHz" + h = estimate_h(y, start, cfo) + + h_true64 = np.fft.fft(np.concatenate([taps, np.zeros(62)])) + nz = LTF_SEQ != 0 + h_true = np.array([h_true64[k % 64] for k in TONES])[nz] + corr = np.corrcoef(np.abs(h[nz]), np.abs(h_true))[0, 1] + print(f"selftest: LTF@{start} cfo_err={cfo_err_khz:.2f} kHz " + f"|H| corr={corr:.4f}") + assert corr > 0.99, f"selftest: |H(k)| correlation {corr:.4f} <= 0.99" + print("selftest: PASS") + return 0 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("dump", nargs="?") + ap.add_argument("--layout", default=None) + ap.add_argument("--json", help="write per-tone JSONL here (default stdout)") + ap.add_argument("--plot", help="write |H|/phase PNG here") + ap.add_argument("--selftest", action="store_true") + args = ap.parse_args() + + if args.selftest: + sys.exit(selftest()) + if not args.dump: + ap.error("dump file required (or --selftest)") + + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import la_decode + meta, words = la_decode.read_dump(args.dump) + if abs(meta["fs_mhz"] - 20.0) > 0.01: + print(f"WARNING: capture at {meta['fs_mhz']} Msps — L-LTF math " + "assumes the standard 20 Msps grid", file=sys.stderr) + layout = args.layout or la_decode.DEFAULT_LAYOUT + x = la_decode.LAYOUTS[layout][1](words) + x = x - np.mean(x) + sys.exit(run(x, args)) + + +if __name__ == "__main__": + main() diff --git a/tools/la_decode.py b/tools/la_decode.py new file mode 100644 index 0000000..a575dbc --- /dev/null +++ b/tools/la_decode.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""LA-mode capture decoder — unpack + score the packed IQ sample layout. + +Input: the "DVLA" dump rxdemo writes for DEVOURER_LA_CAPTURE (32-byte +header + little-endian u64 records, one per 8-byte LA bus word; see +src/LaCapture.h). The vendor C code never documents how I/Q are packed in +the 64-bit word (it depends on dma_type and the chip's dialect), so this +tool carries a set of CANDIDATE layouts and scores them empirically: +capture a CW tone at a known offset (tests/sdr_interferer.py --mode cw) +and the correct layout is the one whose FFT shows a single sharp line at +that offset — wrong bit-slicing scrambles the phase trajectory and smears +the tone into the floor. + + la_decode.py raw cap.bin # vendor "%08x%08x" parity dump + la_decode.py score cap.bin [--offset-mhz 2.0] + la_decode.py unpack cap.bin --layout iq12_l --out iq.c64 + la_decode.py plot cap.bin --layout iq12_l --out psd.png + +Scores: peak-to-median PSD ratio in dB (higher = sharper line = better +candidate); with --offset-mhz the peak must land within ±0.25 MHz of the +expected bin, else the layout scores 0. + +CONFIRMED (tests/la_cw_score.sh, B210 CW at +1/+4/+5 MHz): at +dma_type=0 / dbg_port=0x880 every LA-capable chip packs ONE complex +sample per 64-bit word as 12-bit two's complement with I=[11:0], +Q=[23:12] of data_l — layout "qi12_l" — at exactly the configured +smp_rate (20 Msps verified; residual +0.05 MHz = the crystals' CFO). +Scores: 8822BU 23.6 dB, 8814AU 19.1 dB, 8821C 36.8 dB, 8822CU 50.8 dB. +JGR3 (8822C/E) additionally carries the SECOND RX path in data_h as +I=[55:44], Q=[43:32] ("iq12_h", 47.3 dB, opposite I/Q order) — a +two-chain capture in one shot. +""" + +DEFAULT_LAYOUT = "qi12_l" + +import argparse +import struct +import sys + +import numpy as np + +RATE_MHZ = {0: 80.0, 1: 40.0, 2: 20.0, 3: 10.0, 4: 5.0, 5: 2.5, 6: 1.25, + 7: 160.0} + + +def read_dump(path): + """Return (meta dict, np.uint64 words[]).""" + with open(path, "rb") as f: + hdr = f.read(32) + if hdr[:4] != b"DVLA": + sys.exit(f"{path}: not a DVLA dump") + meta = { + "version": hdr[4], + "trig_mode": hdr[5], + "mac_sig": hdr[6], + "smp_rate": hdr[7], + "dma_type": hdr[8], + "wrap": hdr[9], + "n": struct.unpack_from("> np.uint64(lsb)) & np.uint64((1 << bits) - 1)) + + +# Candidate layouts: name -> (description, unpack(words) -> complex64[]). +# The low dword of each u64 is data_l (the word at the 8-byte address), the +# high dword data_h (at +4) — vendor prints "%08x%08x" as {h}{l}. +def _iq(words, i_lsb, q_lsb, bits): + i = _s(_slice(words, i_lsb, bits), bits).astype(np.float32) + q = _s(_slice(words, q_lsb, bits), bits).astype(np.float32) + return (i + 1j * q).astype(np.complex64) + + +LAYOUTS = { + # 24-bit-populated low dword (8822B/8814A/8821C at dma0/port 0x880): + "iq12_l": ("I=[23:12] Q=[11:0] of data_l", + lambda w: _iq(w, 12, 0, 12)), + "qi12_l": ("Q=[23:12] I=[11:0] of data_l", + lambda w: _iq(w, 0, 12, 12)), + "iq11_l": ("I=[21:11] Q=[10:0] of data_l", + lambda w: _iq(w, 11, 0, 11)), + "qi11_l": ("Q=[21:11] I=[10:0] of data_l", + lambda w: _iq(w, 0, 11, 11)), + "iq10_l": ("I=[19:10] Q=[9:0] of data_l", + lambda w: _iq(w, 10, 0, 10)), + # High-dword variants (JGR3 populates both dwords): + "iq12_h": ("I=[55:44] Q=[43:32] (data_h)", + lambda w: _iq(w, 44, 32, 12)), + "qi12_h": ("Q=[55:44] I=[43:32] (data_h)", + lambda w: _iq(w, 32, 44, 12)), + "iq16_l": ("I=[31:16] Q=[15:0] of data_l", + lambda w: _iq(w, 16, 0, 16)), + "qi16_l": ("Q=[31:16] I=[15:0] of data_l", + lambda w: _iq(w, 0, 16, 16)), +} + + +def psd(x, nfft=4096): + """Averaged periodogram, fftshifted; returns (freq_norm, psd).""" + n = min(len(x), nfft) + if n < 256: + sys.exit("capture too short") + segs = len(x) // n + acc = np.zeros(n) + win = np.hanning(n) + for k in range(segs): + seg = x[k * n:(k + 1) * n] * win + acc += np.abs(np.fft.fft(seg)) ** 2 + acc /= segs + return np.fft.fftshift(np.fft.fftfreq(n)), np.fft.fftshift(acc) + + +def score_layout(x, fs_mhz, offset_mhz=None, tol_mhz=0.25): + """Peak-to-median PSD in dB (0 if the peak misses the expected bin).""" + x = x - np.mean(x) # kill DC so a DC spur can't win + f, p = psd(x) + med = np.median(p) + 1e-12 + pk = int(np.argmax(p)) + pk_mhz = f[pk] * fs_mhz + s = 10 * np.log10(p[pk] / med) + if offset_mhz is not None and abs(pk_mhz - offset_mhz) > tol_mhz: + return 0.0, pk_mhz + return s, pk_mhz + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("cmd", choices=["raw", "score", "unpack", "plot"]) + ap.add_argument("dump") + ap.add_argument("--layout", help="layout name (unpack/plot)") + ap.add_argument("--offset-mhz", type=float, default=None, + help="expected CW offset from channel center (score)") + ap.add_argument("--out", help="output file (unpack: complex64; plot: png)") + ap.add_argument("--nfft", type=int, default=4096) + args = ap.parse_args() + + meta, words = read_dump(args.dump) + print(f"# {args.dump}: n={meta['n']} fs={meta['fs_mhz']}M " + f"dma={meta['dma_type']} wrap={meta['wrap']} " + f"trig={meta['trig_mode']}/{meta['mac_sig']}", file=sys.stderr) + + if args.cmd == "raw": + # Byte-parity with the vendor's is_la_print "%08x%08x" dump. + for w in words: + print(f"{int(w) >> 32:08x}{int(w) & 0xffffffff:08x}") + return + + if args.cmd == "score": + rows = [] + for name, (desc, fn) in LAYOUTS.items(): + x = fn(words) + s, pk = score_layout(x, meta["fs_mhz"], args.offset_mhz) + rows.append((s, name, desc, pk)) + rows.sort(reverse=True) + print(f"{'layout':10s} {'score_db':>8s} {'peak_mhz':>9s} description") + for s, name, desc, pk in rows: + print(f"{name:10s} {s:8.1f} {pk:9.2f} {desc}") + return + + layout = args.layout or DEFAULT_LAYOUT + if layout not in LAYOUTS: + sys.exit(f"unknown layout, one of: {', '.join(LAYOUTS)}") + x = LAYOUTS[layout][1](words) + + if args.cmd == "unpack": + out = args.out or (args.dump + ".c64") + x.tofile(out) + print(f"wrote {len(x)} complex64 samples -> {out}", file=sys.stderr) + return + + if args.cmd == "plot": + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + f, p = psd(x - np.mean(x), args.nfft) + fig, (a1, a2) = plt.subplots(2, 1, figsize=(10, 7)) + a1.plot(f * meta["fs_mhz"], 10 * np.log10(p + 1e-12)) + a1.set_xlabel("offset MHz") + a1.set_ylabel("PSD dB") + a1.set_title(f"{args.dump} layout={layout} fs={meta['fs_mhz']}M") + a1.grid(True, alpha=0.3) + n = min(2000, len(x)) + a2.plot(np.abs(x[:n])) + a2.set_xlabel("sample") + a2.set_ylabel("|x|") + a2.grid(True, alpha=0.3) + out = args.out or (args.dump + ".png") + fig.savefig(out, dpi=100, bbox_inches="tight") + print(f"wrote {out}", file=sys.stderr) + + +if __name__ == "__main__": + main()