Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/cmake-multi-platform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ jobs:
run: >
cmake --build build --target
WiFiDriver StreamTxDemo StreamDuplexDemo StreamStdinSelftest
ToneMaskSelftest BfReportDecodeSelftest
ToneMaskSelftest BfReportDecodeSelftest SweepSpecSelftest

- name: Test
working-directory: build
Expand Down
35 changes: 24 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,22 +302,35 @@ that exercise the salvage path.

## Frequency hopping

`RtlJaguarDevice::FastRetune(channel)` is a lean intra-band, same-bandwidth
channel retune (~1.5 ms vs ~275 ms for a full `SetMonitorChannel`) — it does the
RF channel switch only, skipping the per-rate TX-power loop, bandwidth post-set,
and thermal tick a hop doesn't need, and writes `RF_CHNLBW` from a cached value
so it never pays the C-cut RF-read sleep. It falls back to the full path on a
band change. `send_packet` also honours a radiotap `CHANNEL` field, so hopping is
`IRtlDevice::FastRetune(channel)` is a lean intra-band, same-bandwidth channel
retune every generation implements (default = the full `SetMonitorChannel`): it
does the RF channel switch only, skipping the steady-state work a hop doesn't
need, writes the channel RF register from a cached value, and re-writes the
channel-keyed constants only when their bucket changes. Measured medians:
8812AU ~251→1.6 ms, 8822BU ~62→18 ms, 8821CU ~29→5.6 ms, 8822CU/8812EU
~11→3-4 ms. It falls back to the full path on a band change; on Jaguar3 it
serializes on the coex thread's register mutex (the sanctioned in-session hop)
and preserves the 5/10 MHz narrowband dividers across hops. `send_packet` on
all three generations also honours a radiotap `CHANNEL` field, so hopping is
per-packet and radiotap-driven like rate. Both demos hop via
`DEVOURER_HOP_CHANNELS="1,6,11"` (+ `DEVOURER_HOP_DWELL_FRAMES`,
`DEVOURER_HOP_CHANNELS="1,6,11"` (SweepSpec grammar — also `36-48/4` channel
ranges and `5170-5250/5` MHz ranges; + `DEVOURER_HOP_DWELL_FRAMES`,
`DEVOURER_HOP_ROUNDS`, `DEVOURER_HOP_FAST=0|1|2`, `DEVOURER_HOP_RADIOTAP`,
`DEVOURER_HOP_BW`/`DEVOURER_HOP_OFFSET`). Per-packet hopping doubles as a
frequency-diversity interleaver for the outer FEC. Validated by
`tests/run_hop_validation.sh` (B210 wideband), `tests/hop_parity_check.sh`
(register parity for the 40/80 path), and `tools/precoder/hop_diversity_sim.py`.
The implementation and the in-code techniques are documented in
**`docs/frequency-hopping.md`** (also a porting guideline for other chip
generations).
(family-aware register parity — all three generations dump the same canary
from both paths), and `tools/precoder/hop_diversity_sim.py`. The
implementation, the in-code techniques, and the per-generation ports are
documented in **`docs/frequency-hopping.md`**.

The hop-driven RX counterpart is the sweep/sounding pair: `DEVOURER_RX_SWEEP`
dwells FastRetune-cheap bins emitting per-bin energy + frame stats
(`DEVOURER_RX_SWEEP_FULL=1` forces full retunes; `DEVOURER_RX_AGG_SA=canon`
filters the frame stats to the canonical probe SA), and
`tests/sounding_sweep.sh` + `tests/sounding_map.py` run the two-ended active
sounding that recovers a coarse per-bin H(f) of the link — down to 5 MHz bins
on Jaguar3 (`docs/rx-spectrum-sensing.md`).

`WiFiDriverTxDemo` also honours a TX-gain ramp + duty knob for thermal /
TX-power characterisation (drives `RtlJaguarDevice::SetTxPowerOverride` +
Expand Down
11 changes: 11 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,17 @@ target_link_libraries(ToneMaskSelftest PRIVATE WiFiDriver)

add_test(NAME tone_mask_math COMMAND ToneMaskSelftest)

# Headless guard for the sweep/hop bin-list grammar (src/SweepSpec.h) — the
# channel-list / channel-range / MHz-range parsing behind DEVOURER_RX_SWEEP and
# DEVOURER_HOP_CHANNELS, so a grammar regression fails `ctest` instead of only
# surfacing on a radio.
add_executable(SweepSpecSelftest
tests/sweep_spec_selftest.cpp
)
target_link_libraries(SweepSpecSelftest PRIVATE WiFiDriver)

add_test(NAME sweep_spec_math COMMAND SweepSpecSelftest)

# Headless guard for the beamforming-report decoder (src/BfReportDecode.h) — the
# LSB-first Givens-angle unpacking + per-tone variance behind WiFiSenseDemo. It
# decodes a real captured VHT MU report and checks the angles against an offline
Expand Down
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,14 +210,25 @@ Common to both demos:
mass into higher buckets (measured: peak bucket 5→8 on the 8822CU under a
co-located CW tone). All three generations.
- `DEVOURER_RX_SWEEP="1,6,11"` — coarse **live spectrum map**. Cycle the listed
channels, dwelling `DEVOURER_RX_SWEEP_DWELL_MS` (default 300) on each, and emit
one `<devourer-energy>ch=N` line per bin. The RX loop runs on a worker thread
while the main thread retunes (`SetMonitorChannel`) between reads — one process,
uniform across all three generations. Park a `DEVOURER_CW_TONE` on one channel
(another adapter) and the map peaks (or, on the saturating 1T1R parts, dips) at
that channel. Resolution = the channel grid (20 MHz), down to ~5 MHz on Jaguar3
with `DEVOURER_NB_BW=5`. Render with `tests/rx_spectrum_sweep.sh` +
bins (also accepts channel ranges `36-48/4` and MHz ranges `5170-5250/5`),
dwelling `DEVOURER_RX_SWEEP_DWELL_MS` (default 300) on each, and emit one
`<devourer-energy>ch=N` line per bin carrying the frame-free counters, the
measured `retune_us`, and the dwell's per-frame
`frames/rssi_mean/rssi_max/snr_mean/snr_min/evm_mean` aggregate. The RX loop
runs on a worker thread while the main thread retunes between reads via the
lean `FastRetune` (`DEVOURER_RX_SWEEP_FULL=1` forces the full
`SetMonitorChannel` per dwell) — one process, uniform across all three
generations. Park a `DEVOURER_CW_TONE` on one channel (another adapter) and
the map peaks (or, on the saturating 1T1R parts, dips) at that channel.
Resolution = the channel grid (20 MHz), down to ~5 MHz on Jaguar3 with
`DEVOURER_NB_BW=5`. Render with `tests/rx_spectrum_sweep.sh` +
`tests/rx_spectrum_sweep.py`.
- `DEVOURER_RX_AGG_SA=canon|<mac>` — restrict the per-frame aggregate (energy
telemetry + sweep) to frames whose SA matches (`canon` = the canonical txdemo
beacon SA). The active-sounding filter: a prober hopping fixed-rate beacons
across the sweep's bins (`tests/sounding_sweep.sh`) turns the sweep into a
coarse per-bin H(f) map of the actual link, rendered by
`tests/sounding_map.py` (see `docs/rx-spectrum-sensing.md`).

`WiFiDriverTxDemo`-only knobs:

Expand Down
148 changes: 111 additions & 37 deletions demo/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

#include "BfReportDetect.h"
#include "RxPacket.h"
#include "SweepSpec.h"
#if defined(DEVOURER_HAVE_JAGUAR1)
#include "jaguar1/RtlJaguarDevice.h"
#endif
Expand Down Expand Up @@ -142,50 +143,83 @@ static const uint32_t g_rx_energy_ms = []() -> uint32_t {
return (e && *e) ? static_cast<uint32_t>(std::strtoul(e, nullptr, 0)) : 0;
}();

/* DEVOURER_RX_SWEEP="1,6,11": live coarse spectrum sweep. Cycle the listed
* channels, dwelling DEVOURER_RX_SWEEP_DWELL_MS (default 300) on each, and emit
* one <devourer-energy>ch=N line per bin. The RX loop runs on a worker thread so
* the main thread is free to retune (SetMonitorChannel) between reads — a live
/* DEVOURER_RX_SWEEP="1,6,11" | "36-48/4" | "5170-5250/5": live coarse spectrum
* sweep. Cycle the listed bins (SweepSpec grammar: channels, channel ranges,
* or MHz ranges — the latter for issue-#149-style narrowband maps), dwelling
* DEVOURER_RX_SWEEP_DWELL_MS (default 300) on each, and emit one
* <devourer-energy>ch=N line per bin. The RX loop runs on a worker thread so
* the main thread is free to retune (FastRetune) between reads — a live
* energy-vs-frequency map that localizes an interferer (peaks, or dips on the
* 1T1R parts that saturate, at the tone's channel). Empty = disabled. */
static const std::vector<int> g_rx_sweep = []() -> std::vector<int> {
std::vector<int> out;
const char *e = std::getenv("DEVOURER_RX_SWEEP");
if (!e || !*e) return out;
std::string s = e;
size_t pos = 0;
while (pos < s.size()) {
size_t c = s.find(',', pos);
std::string tok =
s.substr(pos, c == std::string::npos ? std::string::npos : c - pos);
if (!tok.empty()) out.push_back(std::atoi(tok.c_str()));
if (c == std::string::npos) break;
pos = c + 1;
}
return out;
}();
static const std::vector<int> g_rx_sweep =
devourer::parse_sweep_spec(std::getenv("DEVOURER_RX_SWEEP"));
static const uint32_t g_rx_sweep_dwell_ms = []() -> uint32_t {
const char *e = std::getenv("DEVOURER_RX_SWEEP_DWELL_MS");
return (e && *e) ? static_cast<uint32_t>(std::strtoul(e, nullptr, 0)) : 300;
}();

/* Rolling per-frame RSSI/SNR aggregate (the frame-driven signal, all gens):
* updated per received frame in packetProcessor, drained each interval by the
* energy emitter. */
/* Rolling per-frame RSSI/SNR/EVM aggregate (the frame-driven signal, all
* gens): updated per received frame in packetProcessor, drained each interval
* by the energy emitter and per dwell by the sweep loop. EVM counts only
* frames that report one (CCK / non-type1 phy-status leaves it 0), so a mixed
* stream doesn't bias the mean toward 0. */
static std::mutex g_rxagg_mu;
struct RxAgg {
uint32_t n = 0;
int32_t rssi_sum = 0, rssi_max = -128, snr_sum = 0, snr_min = 127;
void add(int rssi, int snr) {
int32_t evm_sum = 0;
uint32_t evm_n = 0;
void add(int rssi, int snr, int evm) {
++n;
rssi_sum += rssi;
if (rssi > rssi_max) rssi_max = rssi;
snr_sum += snr;
if (snr < snr_min) snr_min = snr;
if (evm != 0) { evm_sum += evm; ++evm_n; }
}
};
static RxAgg g_rxagg;

/* The canonical txdemo beacon SA (same constant as txdemo/main.cpp and
* tests/regress.py CANONICAL_SA — change all three together): the
* <devourer-tx-hit> matcher and the "canon" aggregate filter below. */
static const uint8_t kTxSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00};

/* DEVOURER_RX_AGG_SA: restrict the per-frame aggregate to frames whose SA
* matches — the active-sounding filter, so ambient traffic doesn't pollute the
* per-bin link stats (a sweep dwell hears everything on the bin; H(f) wants
* only the probe TX). "canon" = kTxSa; "aa:bb:cc:dd:ee:ff" = that address;
* unset/"any" = every frame (existing behaviour). */
static bool g_agg_sa_filter = false;
static uint8_t g_agg_sa[6];
static const bool g_agg_sa_parsed = []() {
const char *e = std::getenv("DEVOURER_RX_AGG_SA");
if (!e || !*e || std::strcmp(e, "any") == 0)
return false;
if (std::strcmp(e, "canon") == 0) {
std::memcpy(g_agg_sa, kTxSa, 6);
g_agg_sa_filter = true;
return true;
}
unsigned b[6];
if (std::sscanf(e, "%x:%x:%x:%x:%x:%x", &b[0], &b[1], &b[2], &b[3], &b[4],
&b[5]) == 6) {
for (int i = 0; i < 6; i++)
g_agg_sa[i] = static_cast<uint8_t>(b[i]);
g_agg_sa_filter = true;
return true;
}
return false;
}();
static bool agg_sa_match(const Packet &packet) {
if (!g_agg_sa_filter)
return true;
/* SA at offset 10 (FC + duration + addr1) — same layout the tx-hit matcher
* keys on; frames too short to carry it don't count. */
return packet.Data.size() >= 16 &&
std::memcmp(packet.Data.data() + 10, g_agg_sa, 6) == 0;
}

/* Emit the frame-free NHM power histogram (IRtlDevice::GetRxEnergy fills it) as
* a distinct <devourer-nhm> line so it never disturbs the <devourer-energy>
* format its regex consumers key on. `peak` = the fullest bucket (0 = quiet
Expand Down Expand Up @@ -254,11 +288,14 @@ static void packetProcessor(const Packet &packet) {

++g_rx_count;

/* Feed the rolling per-frame RSSI/SNR aggregate for DEVOURER_RX_ENERGY_MS
* (the frame-driven half of the energy telemetry). path-A chain. */
if (g_rx_energy_ms > 0) {
/* Feed the rolling per-frame RSSI/SNR/EVM aggregate for DEVOURER_RX_ENERGY_MS
* and the sweep's per-dwell frame stats (the frame-driven half of the energy
* telemetry). path-A chain; DEVOURER_RX_AGG_SA optionally restricts it to the
* sounding probe's SA. */
if ((g_rx_energy_ms > 0 || !g_rx_sweep.empty()) && agg_sa_match(packet)) {
std::lock_guard<std::mutex> lk(g_rxagg_mu);
g_rxagg.add(packet.RxAtrib.rssi[0], packet.RxAtrib.snr[0]);
g_rxagg.add(packet.RxAtrib.rssi[0], packet.RxAtrib.snr[0],
packet.RxAtrib.evm[0]);
}

if (g_rx_count == 1) {
Expand Down Expand Up @@ -302,7 +339,6 @@ static void packetProcessor(const Packet &packet) {
* one adapter while WiFiDriverTxDemo runs against another on the same
* channel, each hit confirms an injected frame made it over the air. */
if (packet.Data.size() >= 16) {
static const uint8_t kTxSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00};
if (std::memcmp(packet.Data.data() + 10, kTxSa, 6) == 0) {
static int hits = 0;
++hits;
Expand Down Expand Up @@ -681,11 +717,14 @@ int main() {
else std::snprintf(igi, 16, "-");
int rssi_mean = agg.n ? agg.rssi_sum / static_cast<int>(agg.n) : 0;
int snr_mean = agg.n ? agg.snr_sum / static_cast<int>(agg.n) : 0;
int evm_mean =
agg.evm_n ? agg.evm_sum / static_cast<int>(agg.evm_n) : 0;
printf("<devourer-energy>cca_ofdm=%s cca_cck=%s fa_ofdm=%s fa_cck=%s "
"igi=%s frames=%u rssi_mean=%d rssi_max=%d snr_mean=%d "
"snr_min=%d\n",
"snr_min=%d evm_mean=%d\n",
cco, ccc, fao, fac, igi, agg.n, rssi_mean,
agg.n ? agg.rssi_max : 0, snr_mean, agg.n ? agg.snr_min : 0);
agg.n ? agg.rssi_max : 0, snr_mean, agg.n ? agg.snr_min : 0,
evm_mean);
fflush(stdout);
emit_nhm(e, -1);
nap(g_rx_energy_ms);
Expand Down Expand Up @@ -742,19 +781,48 @@ int main() {
fflush(stdout);
}
});
/* Let bring-up complete before the first retune. */
for (int s = 0; s < 2500 && !g_devourer_should_stop; s += 50)
/* Let bring-up complete before the first retune: a retune racing the
* worker thread's init (FW download, DACK/IQK on Jaguar3) interleaves
* register writes with calibration. The first RX frame proves bring-up
* finished; a silent channel falls through after the 10 s cap. */
for (int s = 0; s < 10000 && !g_devourer_should_stop && g_rx_count == 0;
s += 50)
std::this_thread::sleep_for(std::chrono::milliseconds(50));
size_t bi = 0;
/* Dwell retunes ride FastRetune (lean intra-band fast path on every
* generation, internal full-path fallback on a band change);
* DEVOURER_RX_SWEEP_FULL=1 forces the full SetMonitorChannel per dwell
* (A/B escape hatch). */
const bool sweep_full = std::getenv("DEVOURER_RX_SWEEP_FULL") != nullptr;
while (!g_devourer_should_stop) {
int ch = g_rx_sweep[bi % g_rx_sweep.size()];
++bi;
dev->SetMonitorChannel(
SelectedChannel{static_cast<uint8_t>(ch), ch_offset, width});
const auto rt0 = std::chrono::steady_clock::now();
if (sweep_full)
dev->SetMonitorChannel(
SelectedChannel{static_cast<uint8_t>(ch), ch_offset, width});
else
dev->FastRetune(static_cast<uint8_t>(ch), /*cache_rf=*/true);
const long long retune_us =
std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - rt0)
.count();
{
/* Drop frames captured before/during the retune — the dwell's frame
* stats must only cover this bin. */
std::lock_guard<std::mutex> lk(g_rxagg_mu);
g_rxagg = RxAgg{};
}
for (uint32_t s = 0; s < g_rx_sweep_dwell_ms && !g_devourer_should_stop;
s += 50)
std::this_thread::sleep_for(std::chrono::milliseconds(50));
RxEnergy e = dev->GetRxEnergy();
RxAgg agg;
{
std::lock_guard<std::mutex> lk(g_rxagg_mu);
agg = g_rxagg;
g_rxagg = RxAgg{};
}
char cco[16], ccc[16], fao[16], fac[16], igi[16];
auto u = [](char *b, bool v, uint32_t x) {
if (v) std::snprintf(b, 16, "%u", x); else std::snprintf(b, 16, "-");
Expand All @@ -764,9 +832,15 @@ int main() {
u(fao, e.valid_fa, e.fa_ofdm);
u(fac, e.valid_fa, e.fa_cck);
u(igi, e.valid_igi, e.igi);
int rssi_mean = agg.n ? agg.rssi_sum / static_cast<int>(agg.n) : 0;
int snr_mean = agg.n ? agg.snr_sum / static_cast<int>(agg.n) : 0;
int evm_mean = agg.evm_n ? agg.evm_sum / static_cast<int>(agg.evm_n) : 0;
printf("<devourer-energy>ch=%d cca_ofdm=%s cca_cck=%s fa_ofdm=%s "
"fa_cck=%s igi=%s\n",
ch, cco, ccc, fao, fac, igi);
"fa_cck=%s igi=%s retune_us=%lld frames=%u rssi_mean=%d "
"rssi_max=%d snr_mean=%d snr_min=%d evm_mean=%d\n",
ch, cco, ccc, fao, fac, igi, retune_us, agg.n, rssi_mean,
agg.n ? agg.rssi_max : 0, snr_mean, agg.n ? agg.snr_min : 0,
evm_mean);
fflush(stdout);
emit_nhm(e, ch);
}
Expand Down
Loading
Loading