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
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,19 @@ timing. Validation: `tests/run_hop_validation.sh`, `tests/hop_parity_check.sh`
(register parity full-vs-fast). Implementation + per-generation ports:
`docs/frequency-hopping.md`.

**Keyed FHSS + lockstep RX** (`src/HopSchedule.h`): `DEVOURER_HOP_SLOT_MS`
selects monotonic wall-clock slots; `DEVOURER_HOP_SEED` (≤32 hex, a 128-bit key)
replaces the public round-robin with a SipHash-2-4 Fisher-Yates permutation per
round — stateless (`channel = perm(slot/N)[slot%N]`), so a receiver joins
without RNG state. Both orders share the lockstep path: in slot mode `txdemo`
(beacon) and `streamtx` (own frame every `DEVOURER_HOP_SYNC_EVERY`, FEC PSDU
untouched) emit a sync marker (fingerprint/epoch/slot/phase); `rxdemo` hops in
lockstep when `DEVOURER_HOP_CHANNELS`+`_SLOT_MS` are set (seed optional →
keyed/sequential), emitting `hop.rx` acquire/track/retune events. Jammer
resilience: `tests/run_jammer_resilience.sh` (parked B210 A/B/C/D delivery
matrix) + `tests/sdr_follower_jammer.py` (full-duplex B210 follower, reactive vs
predictive). Article + results: `docs/fhss.md`, `docs/jammer-resilience.md`.

`IRtlDevice::FastSetBandwidth(bw)` is the bandwidth analogue — a lean
same-channel toggle between 20 MHz and 5/10 MHz narrowband (the RF stays in
20 MHz mode, so only the baseband ADC/DAC re-clock changes: a single `0x8ac`
Expand Down
4 changes: 4 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,10 @@ target_link_libraries(SweepSpecSelftest PRIVATE devourer)

add_test(NAME sweep_spec_math COMMAND SweepSpecSelftest)

add_executable(HopScheduleSelftest tests/hop_schedule_selftest.cpp)
target_link_libraries(HopScheduleSelftest PRIVATE devourer)
add_test(NAME hop_schedule_math COMMAND HopScheduleSelftest)

# Headless guard for the TX-power offset quantization (src/TxPower.h) — the
# qdB->index-step rounding/clamping behind SetTxPowerOffsetQdb, so a rounding
# regression fails `ctest` instead of only surfacing as a wrong on-air level.
Expand Down
228 changes: 228 additions & 0 deletions docs/fhss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
# Frequency-hopping spread spectrum on a userspace Wi-Fi driver

devourer is a userspace re-implementation of Realtek's RTL88xxAU driver: it
talks to the chip over libusb instead of a kernel module, and it is used by
OpenIPC for long-range digital video links. This article is about turning that
driver into a **frequency-hopping** radio — one that changes channel fast enough
to hop mid-stream, that can hop in a **keyed, unpredictable** order, that a
single second adapter can **follow in lockstep**, and that measurably survives a
jammer. It covers the design, the wire protocol, and every test we ran to
convince ourselves it works, on real hardware.

Two companion documents go deeper where this one summarises: the per-generation
retune mechanics are in [frequency-hopping.md](frequency-hopping.md), and the
jammer-resilience methodology is in [jammer-resilience.md](jammer-resilience.md).

## Why hop at all

A fixed-channel link has two problems in a contested or congested band. It is
**easy to interfere with** — anyone parked on your channel, deliberately or not,
takes you down — and it is **easy to find**: a static carrier is a beacon for
direction-finding. Frequency hopping addresses both. Spreading transmission
across N channels means a narrowband interferer only touches 1/N of your
traffic, which an error-correcting code can ride through; and a short dwell per
channel gives a watcher little to lock onto. If the hop *order* is also secret,
an adversary cannot even predict where to listen or jam next. That last property
is the difference between a hopping link that merely spreads interference and one
that resists a jammer actively trying to chase it.

The catch, on this hardware, is that a Wi-Fi NIC has exactly one local
oscillator per radio and speaks over USB. Every hop is a full RF retune pushed
across a USB control path. Doing that at FHSS rates — hundreds of hops a second —
without stalling the data stream is the engineering problem.

## The hop engine: a lean retune

`IRtlDevice::FastRetune(channel, cache_rf)` is the generation-agnostic entry
point. The default implementation is the full `SetMonitorChannel`; every chip
family overrides it with a stripped-down path. The vendor's channel-set routine
does a great deal that does not change between two channels of the same band and
width — RF calibration, AGC tables, band-edge filters — so the fast path skips
all of it, caches the RF register writes from the first tune, and on subsequent
hops replays only the two-or-so registers that actually encode the new
frequency. The full derivation of each trick is in
[frequency-hopping.md](frequency-hopping.md); the payoff is a per-hop cost that
turns a hop from a visible glitch into a rounding error:

| DUT | full path | fast (cached) |
|-----|-----------|---------------|
| RTL8812AU (Jaguar1) | ~277 ms | **~1.6 ms** |
| RTL8822BU (Jaguar2) | ~65 ms | **~2.5 ms** |
| RTL8821CU (Jaguar2) | ~30 ms | **~0.55 ms** |
| RTL8822CU (Jaguar3) | ~12 ms | **~1.9 ms** |
| RTL8812EU (Jaguar3) | ~12 ms | **~2.4 ms** |

The first hop after a full set primes the cache (~50 ms on Jaguar1); every
same-band hop after that is the number in the right-hand column. Hopping is
radiotap-driven — a frame can carry its own `CHANNEL` field — so it is per-packet
and needs no API change, exactly like rate or bandwidth.

## The schedule: sequential and keyed

A hopset is an ordered list of channels (`DEVOURER_HOP_CHANNELS`, e.g.
`36,40,44,48`). The transmitter advances through it one **slot** at a time.
`DEVOURER_HOP_SLOT_MS` makes the slot a fixed wall-clock interval; the slot
number is just elapsed-time / slot-length, so the schedule is a pure function of
time.

Two orderings share one code path (`src/HopSchedule.h`):

- **Sequential** — the public round-robin `channels[slot mod N]`. Predictable by
anyone who knows the hopset.
- **Keyed** — set `DEVOURER_HOP_SEED` to a 128-bit key and each *round* (a full
pass over the hopset) is a fresh Fisher-Yates permutation, its randomness drawn
from **SipHash-2-4** keyed by the seed and salted by the round number. Every
channel still occurs exactly once per round, but the order is a keyed
pseudo-random permutation an adversary cannot predict without the key.

The important design property is that the keyed schedule is **stateless**:
`channel(slot) = permutation(slot / N)[slot mod N]`, computed from the key and
the absolute slot with no running RNG state. A receiver — or a re-joining one —
derives the same channel for any slot without replaying history. The
permutation uses rejection sampling for an unbiased shuffle, and a headless test
(`tests/hop_schedule_selftest.cpp`, wired into `ctest`) pins the SipHash output
against the reference vectors, checks that every round is a permutation, checks
key- and round-sensitivity, and checks the stateless-lookup identity.

## Lockstep receive: one adapter that follows

A hopping transmitter is invisible to a receiver parked on one channel — it only
hears the ~1/N of frames that happen to land there. The usual answers are N
receivers or a wideband SDR. devourer adds a third: a single adapter that
**hops in lockstep** with the transmitter.

The transmitter periodically emits a small **synchronization marker** — a private
vendor frame carrying the schedule fingerprint (which key/order), a per-process
epoch (to detect a transmitter restart), the current slot, and the in-slot phase
in microseconds. `txdemo` piggybacks it on its beacon; `streamtx` sends it on its
own frame every few data frames, so an application's FEC payload is never
touched. The marker is the shared clock: from `(slot, phase, arrival time)` the
receiver solves for the transmitter's slot-zero epoch and then, because the
schedule is stateless and it knows the key (or that the order is sequential),
computes which channel to be on for every future slot.

`rxdemo` runs a small state machine: **acquire** (scan the hopset until a marker
decodes), **track** (retune per slot on the recovered clock, low-pass-filtering
the phase estimate so it rides out jitter), and re-acquire after a few
marker-free slots. `hop.rx` events expose the state, per-hop retune time, and the
dead time from retune to first decode. Because the marker mechanism is identical
for keyed and sequential, both orders are trackable; the seed is optional.

## Validation

Two independent methods, because each has a blind spot.

### Two-adapter endurance

An RTL8812AU transmits a keyed hopping stream; an RTL8812CU follows it in
lockstep; both are pure userspace devourer. This measures the receiver's ability
to actually decode through the retunes over a long run. A representative session:
**598.3 seconds, 11 968 transmitted slots at 50 ms on channels 36/40/44/48**. The
receiver acquired once, tracked all 11 965 unique slots with no loss/reacquire
transition, and decoded within **11 891 of them (99.38 %)** — every one of the 74
misses an isolated single slot. Cross-channel RX retune ran 2.18 ms median /
2.65 ms p95; post-retune first decode 1.82 ms median. This is the ground truth
that lockstep tracking is real and robust, not a statistical artefact.

### Wideband SDR

The endurance test proves the *receiver* decodes, but a decoder is a poor witness
for the *transmitter*: a sensitive receiver can mask a weak or misordered hop. So
a USRP B210 captures all the hop channels at once and reconstructs the dominant
channel over time, checking it against the keyed schedule the key predicts
(`tests/hop_rx_probe.py`, driven by `tests/run_hop_validation.sh`).

Getting this right was itself instructive. The SDR analysis is a chain of places
to be wrong, and a false negative there looks exactly like a transmitter bug:

- The keyed-sequence matcher originally required an exact contiguous run anchored
at the first observed dwell, with no tolerance for a dropped one. It is now a
gap-tolerant aligner that slides over every offset and resyncs across dropped
or spurious dwells, gated on a match fraction.
- The per-window "which channel is loudest" decision averaged power. The
near-field transmitter is bursty, so averaging let a steady band-edge pedestal
(edge channels sit ~9 dB higher on the B210) win every window and collapse the
sequence. A high-percentile statistic tracks the bursty dwell instead.
- A bounded transmitter run finished during the B210's multi-second bring-up, so
the capture saw only a sliver of hopping. The transmitter now hops
continuously through the whole capture.
- The B210 presents an IQ-mirrored spectrum, handled as a channel involution.

With those fixed, the SDR confirms the keyed order end-to-end: **14 keyed rounds,
overflow-free, at 0.814 match fraction** — comfortably past the strict 10-round
threshold. The aligner ships an offline `--self-test` (no SDR needed) that
reproduces the original matcher failure as a regression guard.

## Jammer resilience

Hopping is only worth the complexity if it buys resilience, so we measured it
end-to-end over the fused-FEC link. Full methodology and knobs are in
[jammer-resilience.md](jammer-resilience.md); the results:

### A parked narrowband jammer

The B210 parks on one channel of the hopset; we measure FEC-decoded delivery
under four transmit strategies (channels 36/40/44/48, jammer on ch 40, 50 ms
slots):

| strategy | fec_delivery |
|----------|--------------|
| static, on a clean channel | ~1.00 |
| static, on the jammed channel | ~0.00 |
| sequential hop | ~0.95 |
| keyed hop | ~0.95 |

The story is clear: a static link that lands on the jammed channel is fully
denied, while hopping bounds the damage to the 1/N of dwells that touch it and
the Reed-Solomon + sub-block code recovers that erasure fraction. **Against a
blind parked jammer, sequential and keyed are identical** (repeat runs 0.948 /
0.950 vs 0.947 / 0.950) — the jammer cannot exploit an order it does not
observe. Which is the whole point: the order only has to be secret against a
jammer that *reacts*.

### A following jammer

So we built one. `tests/sdr_follower_jammer.py` chases the hopping transmitter.
The B210 is a 2×2 (two receive and two transmit frontends off one AD9361), so it
**senses on RX while jamming on TX simultaneously** — no time-multiplexing. A
wideband RX burst (one FFT spans the hopset) finds the transmitter's current
channel; a CW tone on the TX frontend denies it, retuned when the target moves.
Two strategies, matched to the order it faces:

- **reactive** (against keyed): jam where the transmitter was just sensed. A hit
needs the transmitter still there after the sense-and-retune latency.
- **predictive** (against sequential): jam the channel the public order says
comes *next*, which cancels the follower's own latency.

Measured on this rig, the follower's reaction is dominated by the B210 transmit
retune at **~3.5 ms** (wideband sensing itself costs ~0.3 ms). That latency is
the floor on how fast any reactive follower here can move, and it **favours the
defender**. The two strategies' chase dynamics diverge sharply: over a 20-second
run against a 50 ms-slot transmitter, the **reactive** follower issues ~2100
retunes (constant correction — always a step behind an unpredictable hop) while
the **predictive** follower issues ~450 (it pre-positions and holds). That ≈5×
gap *is* the keyed schedule forcing the jammer into a latency-bound reactive
chase instead of a free ride.

The dwell threshold where following breaks is set by that reaction latency: once
the slot dwell falls below it (~3.5 ms here), a reactive jammer can no longer
land on a keyed hopper, while a predictive jammer against a sequential hopper is
limited only by its retune time and holds to much shorter dwells. The gap
between those two thresholds is exactly what a keyed permutation buys.

## Where it stands, and what is next

What works today, on hardware: fast intra-band retune on all three chip
generations; sequential and keyed slot-hopping driven purely by radiotap and
environment; single-adapter lockstep receive validated to 99.38 % over ten
minutes and cross-checked by a wideband SDR; and a measured resilience story
against both a parked and a following jammer.

The honest edges: retune is intra-band (a cross-band hop correctly falls back to
the slow full set); per-hop TX power is not re-tuned, so a hopset spanning a wide
5 GHz range wants a periodic full set to refresh per-rate power; the follower
experiment uses a 3-channel hopset because a single B210 needs ≥60 MS/s to span
a 60 MHz hopset in one FFT and trips a UHD tuning assertion at the usual
61.44 MS/s. And the natural next step — **adaptive hopset exclusion**, dropping a
persistently-jammed channel from the set so the link stops visiting it at all —
is future work.
31 changes: 31 additions & 0 deletions docs/frequency-hopping.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,37 @@ channel write, strip the per-hop work down to it, kill the read-modify-write
penalty) applies to any Realtek generation, so the "Porting to other
generations" section at the end generalises each trick.

## Keyed FHSS and lockstep receive

Setting `DEVOURER_HOP_SEED` to up to 32 hexadecimal digits replaces the public
round-robin order with a SipHash-2-4-driven Fisher-Yates permutation for every
round. The schedule is a pure function of the 128-bit key and absolute slot:
every channel occurs exactly once per round and a receiver can join without
replaying RNG state. With no seed, the order is the public sequential
round-robin (the two share the same lockstep machinery, so both are trackable).

`DEVOURER_HOP_SLOT_MS=N` selects monotonic wall-time slots instead of
`DEVOURER_HOP_DWELL_FRAMES`; explicitly setting both is an error. In slot mode
both `txdemo` and `streamtx` emit a versioned private synchronization marker —
a seed fingerprint, process epoch, slot, and in-slot phase. `txdemo` carries it
on the beacon; `streamtx` sends it on its own frame every
`DEVOURER_HOP_SYNC_EVERY` data frames (default 4) so the caller's FEC PSDUs stay
untouched.

`rxdemo` enters single-adapter lockstep mode when `DEVOURER_HOP_CHANNELS` and
`DEVOURER_HOP_SLOT_MS` are present (the seed is optional — with it the RX tracks
the keyed permutation, without it the sequential order). It parks on the first
channel, scans the hopset after `DEVOURER_HOP_ACQUIRE_MS` (default two slots),
then tracks the transmitted slot clock. Three marker-free slots return it to
acquisition. `hop.rx` events expose state, retune time, phase correction inputs,
and first-decode dead time. This mode is mutually exclusive with
`DEVOURER_RX_SWEEP`.

The full story — the schedule, lockstep, validation, and jammer-resilience
measurements — is written up as an article in [fhss.md](fhss.md); the
jammer-resilience experiments have their own methodology note in
[jammer-resilience.md](jammer-resilience.md).

## Why frequency is not like MCS

devourer already lets a single frame pick its own rate / bandwidth / STBC /
Expand Down
Loading
Loading