Skip to content

RX spectrum sensing: frame-free energy sensor, spectrum sweep, per-tone localizer#177

Merged
josephnef merged 1 commit into
masterfrom
rx-spectrum-sensing
Jul 5, 2026
Merged

RX spectrum sensing: frame-free energy sensor, spectrum sweep, per-tone localizer#177
josephnef merged 1 commit into
masterfrom
rx-spectrum-sensing

Conversation

@josephnef

Copy link
Copy Markdown
Collaborator

The receive-side counterpart to DEVOURER_CW_TONE: use a cheap Wi-Fi adapter as
a spectrum sensor / interferer detector, across all three chip generations. No
Realtek 88xx chip exports per-subcarrier CSI to the host, so this surfaces the
channel-wide and per-tone energy the silicon does expose. The elegant
validation is two-adapter, no SDR: one adapter emits DEVOURER_CW_TONE, another
detects it.

Energy sensor — DEVOURER_RX_ENERGY_MS

Emits a <devourer-energy> line combining the phydm false-alarm + CCA
(channel-busy) counters and DIG IGI (all frame-free) with a rolling per-frame
RSSI/SNR aggregate. Jaguar1/2 read the classic-AC register map; the Jaguar3 HAL
gains a new port of the FA/CCA/IGI machinery (CCA 0x2c08, CCK-FA 0x1a5c,
OFDM-FA sum, IGI 0x1d70) so all three generations are first-class frame-free
sensors.

A companion <devourer-nhm> line carries the NHM noise histogram — a
12-bucket, IGI-referenced in-band power distribution ported from phydm CCX, one
shared algorithm (src/NhmReader.h) with a per-generation register map (11AC for
Jaguar1/2, JGR3 for Jaguar3). An in-band interferer shifts the histogram's mass
into higher power buckets.

Cross-generation plumbing: a neutral RxEnergy snapshot (src/RxSense.h) and an
optional IRtlDevice::GetRxEnergy() virtual overridden per generation.

Spectrum map — DEVOURER_RX_SWEEP

A live, single-process coarse spectrum map: the RX loop runs on a worker thread
while the main thread retunes between energy reads, emitting one
<devourer-energy>ch=N line per bin. Park a tone on one channel and the map
peaks (or, on the saturating 1T1R parts, dips) at that channel.

This also fixes RtlJaguar2Device::SetMonitorChannel, previously a no-op that
never retuned — which silently broke Jaguar2 frequency hopping via
SetMonitorChannel too.

Per-tone localizer

tests/rx_tone_localize.py locates a narrowband interferer from the
self-sounding beamforming report (per-tone SNR notch + per-tone ψ cross-frame
variance) at sub-channel (~385 kHz) resolution. tools/bf_report_decode.py gains
a reusable analyze_frames()/read_frames() entry point shared with the
localizer (decoder output unchanged, verified byte-identical).

Testing

  • New headless CTest rx_tone_localize_math guards the localizer's detection +
    frequency-mapping math; ctest stays green (3/3).
  • Hardware-validated with two adapters (CW tone vs sensor), no SDR:
    • Energy sensor: cca_ofdm spike (~13–380× on the 2T2R 8822CU) or collapse
      (1T1R AGC saturation), tone on/off.
    • NHM: histogram peak bucket shifts 5→8 on the 8822CU (JGR3 map) under a
      co-located CW tone; the 11AC map reads correct populated histograms with the
      high buckets filling under the tone.
    • Spectrum sweep: map peaks at the tone's channel on both a Jaguar2 (8812BU)
      and Jaguar3 (8822CU) sensor.
  • Validation harnesses: tests/rx_energy_probe.sh, tests/rx_spectrum_sweep.sh,
    tests/rx_tone_localize.sh (+ Python checkers/renderers).

Docs: docs/rx-spectrum-sensing.md (new) + README env-var reference.

🤖 Generated with Claude Code

…ne localizer

Add a receive-side spectrum-sensing / interferer-detection capability — the
inverse of DEVOURER_CW_TONE — across all three chip generations. No Realtek
88xx chip exports per-subcarrier CSI to the host, so this surfaces the
channel-wide and per-tone energy the silicon does expose.

Cross-generation plumbing:
  - RxEnergy (src/RxSense.h): a neutral frame-free energy snapshot.
  - IRtlDevice::GetRxEnergy(): optional virtual, overridden per generation.

Energy sensor — DEVOURER_RX_ENERGY_MS:
  - Emits a <devourer-energy> line combining the phydm false-alarm + CCA
    counters and DIG IGI (frame-free) with a rolling per-frame RSSI/SNR
    aggregate. Jaguar1/2 read the classic-AC map; the Jaguar3 HAL gains a new
    port of the FA/CCA/IGI machinery (CCA 0x2c08, CCK-FA 0x1a5c, OFDM-FA sum,
    IGI 0x1d70) so all three generations are first-class.
  - NHM noise histogram (src/NhmReader.h): a 12-bucket, IGI-referenced in-band
    power distribution, ported from phydm CCX with one shared algorithm and a
    per-generation register map (11AC for Jaguar1/2, JGR3 for Jaguar3). Emitted
    as a companion <devourer-nhm> line; an interferer shifts the histogram mass
    into higher power buckets.

Spectrum map — DEVOURER_RX_SWEEP:
  - A live, single-process coarse spectrum map: the RX loop runs on a worker
    thread while the main thread retunes between energy reads, emitting one
    <devourer-energy>ch=N line per bin. Fixes RtlJaguar2Device::SetMonitorChannel,
    previously a no-op that never retuned (which also broke Jaguar2 frequency
    hopping via SetMonitorChannel).

Per-tone localizer:
  - tests/rx_tone_localize.py locates a narrowband interferer from the
    self-sounding beamforming report (per-tone SNR notch + per-tone psi
    cross-frame variance), at sub-channel resolution. tools/bf_report_decode.py
    gains a reusable analyze_frames()/read_frames() entry point (decoder output
    unchanged). A headless CTest (rx_tone_localize_math) guards the detection +
    frequency-mapping math.

Docs (docs/rx-spectrum-sensing.md, README) and validation harnesses
(rx_energy_probe.sh, rx_spectrum_sweep.sh, rx_tone_localize.sh + checkers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@josephnef josephnef force-pushed the rx-spectrum-sensing branch from 95e51df to eb297d8 Compare July 5, 2026 12:59
@josephnef josephnef merged commit 0a7b9eb into master Jul 5, 2026
15 checks passed
@josephnef josephnef deleted the rx-spectrum-sensing branch July 5, 2026 13:06
josephnef added a commit that referenced this pull request Jul 5, 2026
…#186)

Completes the narrowband noise/sounding sweep (#149): the fast retune
the sweep dwells on, and the active two-ended sounding mode on top of
the #177 spectrum map. Closes #149.

## FastRetune on every generation

`IRtlDevice::FastRetune(channel)` is now the generation-agnostic lean
intra-band retune (default = full `SetMonitorChannel`), with a real fast
path on all three generations — the RF channel write plus channel-keyed
constants on bucket change, everything bandwidth/band-keyed untouched,
full-path fallback on a band change:

| DUT | full path | fast |
|-----|-----------|------|
| RTL8812AU | ~251 ms | **~1.6 ms** |
| RTL8822BU | ~62 ms | **~18 ms** |
| RTL8821CU | ~29 ms | **~5.6 ms** |
| RTL8822CU | ~11 ms | **~3.6 ms** |
| RTL8812EU | ~11 ms | **~4.3 ms** |

- **Jaguar2** (both variants): one cached full-register RF18 write
collapses the full path's read-modify-write rounds (three on the 8821C);
AGC index / CFO fc / RF 0xBE VCO band / ch144 RF 0xDF / spur +
CCK-filter regs written on bucket change; per-hop RX kick kept.
- **Jaguar3** (both variants): RF18 inside its 3-wire bracket +
force-anapar + BB reset; lazily-primed 8822c RF18 cache (primed on the
first fast hop — init-time IQK rewrites RF18 after the channel set, so
priming earlier would cache a stale value); 5/10 MHz narrowband dividers
are preserved across hops (no per-dwell re-clock + DAC-FIFO reset).
`FastRetune` and `SetMonitorChannel` now serialize on the coex-thread
register mutex — in-session hops no longer race the housekeeping tick.
- Radiotap `CHANNEL` drives a per-packet FastRetune on **all three
generations** now (was Jaguar1-only).
- The subtle rule the parity oracle taught on hardware: init-time
calibration rewrites BW-keyed state *after* the channel set (halrf
clears the 40 MHz TX_CCK_IND bit in the 8822e's RF 0x1a; the NB recipe
owns `0x808[6:4]`), and the full path re-imposes it per retune — so the
fast path re-asserts that state once per cache epoch. Correctness is
**end-state** parity with the full path.

## Sweep upgrades

- Dwell retunes ride FastRetune (`DEVOURER_RX_SWEEP_FULL=1` forces the
full path), and each `ch=N` line reports the measured `retune_us`.
- Per-dwell frame stats on the bin line:
`frames/rssi_mean/rssi_max/snr_mean/snr_min/evm_mean`, drained at dwell
start so retune transients never leak into a bin.
`DEVOURER_RX_AGG_SA=canon|<mac>` restricts the aggregate to one
transmitter's SA.
- `DEVOURER_RX_SWEEP` / `DEVOURER_HOP_CHANNELS` share one bin-list
grammar (`src/SweepSpec.h`): channel lists, channel ranges (`36-48/4`),
and MHz ranges (`5170-5250/5`). Headless `sweep_spec_math` ctest.

## Active two-ended sounding — coarse H(f) of the link

The passive map is blind to fading of our own path; the active mode
probes it. `tests/sounding_sweep.sh`: one end hops fixed-rate
canonical-SA probe beacons across the bins via FastRetune, the other
sweeps the same bins with the SA filter — synchronization is
asymmetric-duty (RX dwell ≥ ~2 TX cycles), no control channel.
`tests/sounding_map.py` renders the map: per-bin median of `rssi_max`
(off-channel bleed decodes weaker, never stronger — bleed-robust),
NOTCH/DEAD flags, optional Spearman cross-check against a B210 capture
(`hop_rx_probe.py --bin-power-csv`).

## Register-parity tooling

`DumpCanary` ported to Jaguar2 + Jaguar3 (same format, emitted from both
the full and fast paths); `tests/hop_parity_check.sh` is family-aware
(Jaguar1 keeps its TX-power/IQK exclusions plus the live RF 0x00/0x42
registers the full-vs-full control only catches probabilistically;
Jaguar2/3 exclude nothing).

## Test-harness fix

The kernel truncates comm to 15 chars, so every `pkill -x
WiFiDriverTxDemo` cleanup in `tests/*.sh` was a silent no-op (leaked TX
demos held adapters across scripts). Fixed to the real truncated comm
across the harness.

## Testing

- Build clean on the full config and the per-chip subsets; `ctest` green
(5/5, incl. the new `sweep_spec_math`).
- **Register parity** (full-vs-fast, with full-vs-full control): PASS on
8812AU (5G/40), 8822BU (2.4G + 5G), 8821CU (2.4G + 5G), 8822CU (2.4G/20,
5G/20, 5G/40, NB5), 8812EU (2.4G/20, 5G/40, NB5). The oracle caught two
real fast-path bugs on the 8812EU before they shipped (the end-state
rule above).
- **B210 on-air hop order**: PASS on 8822CU (12 cycles), 8812EU (23),
Archer T3U (6), and the radiotap-driven per-packet mode.
- **Sweep A/B** (CW tone on ch6): fast and full dwells localize the same
peak at 20 MHz and at 5 MHz NB on both Jaguar3 variants. NB caveat
captured in the docs: a CW at an NB bin's own centre hides in the
receiver's DC null and registers on the adjacent bins (identical on both
paths — chip physics).
- **Sounding**: 8812AU↔8822CU at 2.4 GHz (flat bench map, correct null
case, B210 cross-check wired); 8822CU→8812EU at `--bins 5170-5250/5
--nb-bw 5` recovered a real H(f) — a smooth ~15 dB notch centred at 5230
MHz with monotone roll-in/out across all 17 five-MHz bins; reverse
direction verified at 20 MHz (this 8812EU specimen's NB emission is
marginal — same unit with the dead 2.4 GHz RX).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant