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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,11 @@ sandbox; and [`docs/fused-fec.md`](docs/fused-fec.md) — the cross-layer
(PHY-MCS ⊕ sub-block-integrity ⊕ outer erasure) FEC stack the link's per-layer
quality SLA is stated against.

New to the low-level RF machinery? [`docs/rf-primer.md`](docs/rf-primer.md) is a
visual primer — four short animations (the OFDM channel, the constellation, a
tone vs a modulated carrier, and AGC saturation) that make the rest of the docs
click.

### Startup time

Devourer reaches ready-to-RX/TX faster than the `aircrack-ng/88XXau`
Expand Down
Binary file added docs/img/agc_saturation.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/constellation.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/ofdm_anatomy.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/spectrum_compare.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
85 changes: 85 additions & 0 deletions docs/rf-primer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# A visual primer on the RF machinery

devourer talks to a Wi-Fi radio at a very low level — subcarriers, constellations,
gain control, the transmit and receive chains. If you're new to that machinery,
the terms in the other docs (per-tone SNR, EVM, CCA, AGC, occupied bandwidth) can
feel like jargon. This page is a picture book: four short animations, each
built in the DEVOURER live-monitor style, that show what the machinery actually
looks like. Read it top to bottom and the rest of the docs will click.

Everything here is grounded in what devourer measures — the constellation noise
follows the textbook AWGN model, the spectrum levels are from a real USRP B210
capture, and the AGC behaviour is the very effect the energy sensor keeps seeing.

## 1. The channel — what a "subcarrier" is

![OFDM channel anatomy](img/ofdm_anatomy.gif)

A Wi-Fi channel isn't one frequency — it's a comb of many narrow **subcarriers**.
A 20 MHz channel is 64 of them, spaced 312.5 kHz apart: a **DC null** left empty
in the middle, a handful of **pilot** tones the receiver uses to track drift,
dozens of **data** tones that carry the bits, and empty **guard** bins at the
edges so the signal doesn't spill into the neighbours. Wider channels (40/80 MHz)
just add more of the same 312.5 kHz tones; the narrowband 5/10 MHz modes re-clock
to *closer* spacing to fit a thin channel (more robust, less throughput).

This comb is the coordinate system everything per-tone lives in — the
[per-subcarrier SNR waterfall](beamforming-self-sounding.md), the
[NHM power buckets](rx-spectrum-sensing.md), and the tone mask all index into it.

## 2. Modulation — how bits ride the signal, and why SNR matters

![IQ constellation vs SNR](img/constellation.gif)

Each subcarrier carries bits by taking one of a set of points on the I/Q plane —
the **constellation**. QPSK has 4 points (2 bits each); 256-QAM packs 256 points
(8 bits each). More points = more bits per symbol = more throughput. The catch:
noise nudges each received point away from its ideal spot (that displacement is
the **EVM**), and if it drifts across the boundary into a neighbouring point's
cell, that's a **bit error**. The animation holds one channel and climbs the
modulation: QPSK's points are far apart so there's margin to spare, but 256-QAM
packs them so tight the *same* noise smears the clusters together and the link
breaks. That boundary — the highest modulation a given SNR can hold — is exactly
what the [MCS-headroom probe](adaptive-link-building-blocks.md) measures.

## 3. On the air — a bare tone vs a modulated carrier

![CW tone vs modulated spectrum](img/spectrum_compare.gif)

Look at the same signals on a **spectrum analyzer** (power vs frequency). A
**CW tone** puts all its energy at one frequency — a single tall spike, nearly
zero bandwidth; it's a clean narrowband probe or interferer
(`DEVOURER_CW_TONE`). A **modulated carrier** spreads its energy across every
subcarrier — a flat block filling the whole 20 MHz (`DEVOURER_CONT_TX`); it's what
real traffic looks like, and the realistic stimulus for link probing. Same
transmitter, two completely different spectral footprints. (Levels here are a real
B210 capture on ch100: a −25 dB floor, the tone ~+18 dB above it, the block ~+28.)

## 4. At the receiver — gain control, and why a strong signal goes deaf

![AGC gain and saturation](img/agc_saturation.gif)

The receiver can't handle every signal level directly, so an **AGC** (automatic
gain control) turns its gain up for weak signals and down for strong ones, aiming
to keep the ADC in its sweet spot. But the gain has a floor. When a signal is
*too* strong — a transmitter co-located inches away — the AGC runs out of
attenuation, the ADC input exceeds full scale, and the waveform **clips** flat
against the rails. A clipped waveform can't be demodulated: the receiver goes
deaf. That's why, in the [sensing docs](rx-spectrum-sensing.md), a *moderate*
interferer makes the CCA counter **spike** while a *strong* co-located one makes
frames and CCA **collapse** toward zero — the AGC saturating is the collapse.

---

## Where to go next

With the machinery in hand, the rest reads straight:

- [`rx-spectrum-sensing.md`](rx-spectrum-sensing.md) — reading energy, noise, and
interference off that channel comb, frame-free (includes the animated NHM
monitor).
- [`beamforming-self-sounding.md`](beamforming-self-sounding.md) — measuring the
per-subcarrier channel with two adapters (the animated SNR waterfall).
- [`adaptive-link-building-blocks.md`](adaptive-link-building-blocks.md) — the
levers, sensors, and probes that turn all of the above into an adaptive link,
and [`adaptive-link.md`](adaptive-link.md) — the objective they serve.
8 changes: 8 additions & 0 deletions tests/sdr_spectrum.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ def main() -> int:
ap.add_argument("--args", default="type=b200")
ap.add_argument("--duration", type=float, default=1.5)
ap.add_argument("--label", default="")
ap.add_argument("--dump", default=None,
help="write freq_MHz,psd_dB CSV (for the docs spectrum diagram)")
args = ap.parse_args()

nsamp = max(1 << 16, int(args.rate * args.duration))
Expand Down Expand Up @@ -111,6 +113,12 @@ def main() -> int:
print(f" {line}")
print(f" peakiness={peakiness:.1f} dB occ_bw={occ_mhz:.1f} MHz "
f"(tone: high peakiness/low occ; modulated: low peakiness/wide occ)")
if args.dump:
with open(args.dump, "w") as fh:
fh.write("freq_mhz,psd_db\n")
for f, p in zip(freqs, psd):
fh.write(f"{(args.freq + f) / 1e6:.4f},{p:.3f}\n")
print(f" wrote {args.dump}")
return 0


Expand Down
140 changes: 140 additions & 0 deletions tools/agc_gif.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Animated AGC monitor — 'why a strong signal makes the receiver go deaf', in
the DEVOURER live-monitor style.

tools/agc_gif.py -o docs/img/agc_saturation.gif

The receiver's automatic gain control (AGC) turns its gain down as the incoming
signal gets stronger, to keep the ADC in its sweet spot. But the gain has a
floor: when a signal is *too* strong — a co-located carrier inches away — the AGC
runs out of attenuation, the ADC input exceeds full scale, and the waveform clips
flat against the rails. A clipped waveform can't be demodulated: the RX goes
deaf. That is the 'collapse' the frame-free energy sensor sees when a strong
interferer arrives (frames and CCA fall toward zero) — the flip side of the
'spike' a moderate one causes. Needs Pillow.
"""
from __future__ import annotations

import argparse
import math
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from monitor_style import (AMBER, CYAN, DIM, GRID, INK, OK, WARN, chrome, font,
new_frame, save_gif)

TARGET = 0.42 # ADC level the AGC aims for (fraction of full scale)
GMIN, GMAX = 0.16, 6.0
NS = 220 # waveform samples across the scope


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("-o", "--out", default="agc_saturation.gif")
ap.add_argument("--frames", type=int, default=64)
ap.add_argument("--ms", type=int, default=85)
args = ap.parse_args()

padL, padT, padB = 34, 92, 46
scope_w, scope_h = 500, 300
panelW = 300
W = padL + scope_w + 24 + panelW
H = padT + scope_h + padB
cy = padT + scope_h // 2
rail = scope_h // 2 - 10 # full-scale rail offset from centre

imgs = []
for fi in range(args.frames):
# interferer approaches and recedes: input level (full-scale units at
# unity gain) sweeps low -> very high -> low.
u = fi / args.frames
env = 0.5 - 0.5 * math.cos(2 * math.pi * u) # 0..1..0
inp = 0.05 * math.exp(env * 5.3) # 0.05 .. ~10 (peak clips)
gain = min(GMAX, max(GMIN, TARGET / inp)) # AGC decision
adc = inp * gain # ADC input level
clipping = adc > 1.0
floored = gain <= GMIN + 1e-3

img, d = new_frame(W, H)
chrome(d, W, H, "AGC / ADC MONITOR",
"the receiver turns gain down as signal rises — until the gain "
"floor, where the ADC clips", fi)

# scope box + rails
d.rectangle([padL, padT, padL + scope_w, padT + scope_h],
outline=(0, 70, 80))
for sgn in (1, -1):
yr = cy - sgn * rail
d.line([padL, yr, padL + scope_w, yr], fill=(90, 40, 40))
d.text((padL + 6, cy - rail - 14), "+full scale", font=font(10),
fill=(150, 80, 80))
d.text((padL + 6, cy + rail + 2), "−full scale", font=font(10),
fill=(150, 80, 80))
# saturation zones (above/below rails) tinted when clipping
if clipping:
d.rectangle([padL + 1, padT + 1, padL + scope_w - 1, cy - rail],
fill=(40, 12, 12))
d.rectangle([padL + 1, cy + rail, padL + scope_w - 1,
padT + scope_h - 1], fill=(40, 12, 12))
# the ADC waveform (sine at `adc` amplitude, clipped at the rails)
pts = []
for i in range(NS):
ph = i / NS * 6 * math.pi + fi * 0.35
s = math.sin(ph) * adc
s = max(-1.0, min(1.0, s)) # clip
x = padL + int(i / (NS - 1) * scope_w)
pts.append((x, cy - int(s * rail)))
wcol = WARN if clipping else OK
d.line(pts, fill=wcol, width=2)

# readout panel
x0 = padL + scope_w + 22
d.text((x0, padT - 2), "LIVE READOUT", font=font(12), fill=CYAN)
y = padT + 22

def bar(lbl, frac, col, txt):
nonlocal y
d.text((x0, y), lbl, font=font(11), fill=DIM)
d.text((x0 + 150, y - 1), txt, font=font(13, True), fill=col)
y += 16
bw = 260
d.rectangle([x0, y, x0 + bw, y + 12], outline=(40, 52, 68))
d.rectangle([x0, y, x0 + int(bw * max(0, min(1, frac))), y + 12],
fill=col)
y += 26

in_db = 20 * math.log10(inp / 0.05) # relative dB
g_db = 20 * math.log10(gain)
bar("input signal", inp / 10.5, AMBER if inp > 1 else CYAN,
f"{in_db:+4.0f} dB")
bar("AGC gain", (gain - GMIN) / (GMAX - GMIN),
WARN if floored else OK, f"{g_db:+4.0f} dB")
bar("ADC level", min(1.0, adc), WARN if clipping else OK,
"CLIP" if clipping else f"{int(adc*100):d} %")

# status pill
if clipping:
st, sc, sub = "SATURATED — RX DEAF", WARN, "energy sensor sees a COLLAPSE"
elif floored:
st, sc, sub = "GAIN FLOORED", AMBER, "no headroom left"
else:
st, sc, sub = "LOCKED", OK, "ADC in its sweet spot"
y += 6
d.rectangle([x0, y, x0 + 286, y + 32], outline=sc, width=2)
d.ellipse([x0 + 9, y + 11, x0 + 20, y + 22], fill=sc)
d.text((x0 + 30, y + 8), st, font=font(14, True), fill=sc)
y += 40
d.text((x0, y), sub, font=font(11), fill=INK)

d.text((padL, padT + scope_h + 20),
"a moderate interferer → CCA spike; a strong one → AGC floors, "
"RX collapses", font=font(11), fill=DIM)
imgs.append(img)

save_gif(imgs, args.out, ms=args.ms)
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading