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 @@ -137,6 +137,12 @@ Both `WiFiDriverDemo` and `WiFiDriverTxDemo` honour:
`0x8813` for RTL8814AU); without it, the demo iterates every Realtek PID.
- `DEVOURER_VID=0xNNNN` — override VID (default `0x0bda`); needed for
OEM-rebadged dongles like the TP-Link Archer T2U Plus (`2357:0120`).
- `DEVOURER_USB_BUS=N` (+ optional `DEVOURER_USB_PORT=a.b.c`) — select a device
by USB topology instead of first-match VID:PID. Needed when two adapters share
one VID:PID **and serial** and can't otherwise be told apart (two RTL8814AU
dongles, e.g. CF-938AC vs CF-960AC). `DEVOURER_USB_PORT` is the dotted libusb
port path (sysfs `devpath` / `lsusb -t`). Unset = the normal VID:PID open loop.
Used by `tests/compare_8814_decorrelation.sh`.
- `DEVOURER_CHANNEL=N` — override monitor channel.
- `DEVOURER_SKIP_RESET=1` — skip `libusb_reset_device` before claim; useful
when picking up a chip whose firmware is already running.
Expand All @@ -150,6 +156,13 @@ Both `WiFiDriverDemo` and `WiFiDriverTxDemo` honour:
output. This is the entry point for the fused-FEC sub-block-salvage layer
(see `docs/fused-fec.md`); opt-in, since a body with a corrupt tail is the
worst-case input for an IP-stack consumer that didn't ask for it.
- `DEVOURER_RX_ALLPATHS=1` — emit a `<devourer-rxpath>` line per canonical-SA
frame carrying all four RX chains (A,B,C,D) of per-stream `rssi`/`snr`/`evm`,
where the canonical `<devourer-stream>`/`<devourer-body>` lines surface only
A,B. Paths C/D are non-zero only on the 8814AU (4T4R); 0 on 2T2R parts.
Opt-in and on a distinct tag so the two-path format its regex consumers key on
is untouched. Consumed by `tests/antenna_decorrelation.py` to measure
inter-chain envelope correlation and realised diversity gain.
- `DEVOURER_USB_DEBUG=1` — raise libusb log level from the default WARNING to
DEBUG (produces ~7 MB per 15 s — has filled `/tmp` mid-capture and adds
0.5-0.8 s to init even with stderr discarded). `DEVOURER_USB_QUIET` is
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ varies run-to-run (bracketed = best clean reading).
| ----------------------------- | ----------------- | ------------- | ------------- | ---------------- | ------------------------------------------- |
| **RTL8812AU** | 2T2R | 56 | 52 | 52 | `0bda:8812`; reference part |
| **RTL8811AU** | 1T1R | mirrors 8812 | mirrors 8812 | mirrors 8812 | 1T1R cut of 8812 silicon; rides the 8812 code path (`RFType=RF_TYPE_1T1R` from `REG_SYS_CFG` bit 27). Not benchmarked |
| **RTL8814AU** | 4T4R, 3-SS max | 65 | †(32) | †(32) | `0bda:8813`; 2-SS effective on USB-2 |
| **RTL8814AU** | 4T4R, 3-SS max | 65 | †(32) | †(32) | `0bda:8813`; tested on COMFAST CF-938AC (2 ext antennas) and CF-960AC (4 internal) — effective RX-diversity branches differ (N_eff ≈ 3.8 vs 2.6) despite identical silicon, see [`docs/effective-branches-rotation.md`](docs/effective-branches-rotation.md) |
| **RTL8821AU** | 1T1R AC + BT | 54 | 32 | 28 | TP-Link Archer T2U Plus (`2357:0120`) |
| **RTL8812CU** | 2T2R | 65 | 60 | 60 | LB-LINK WDN1300H (`0bda:c812`) |
| **RTL8822CU** | 2T2R + BT | — | — | — | not benchmarked (`0bda:c82c`) |
Expand Down
58 changes: 58 additions & 0 deletions demo/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,27 @@ static void packetProcessor(const Packet &packet) {
static const bool keep_corrupted =
std::getenv("DEVOURER_RX_KEEP_CORRUPTED") != nullptr;
const bool corrupted = packet.RxAtrib.crc_err || packet.RxAtrib.icv_err;
/* DEVOURER_RX_ALLPATHS=1: emit all four RX chains (A,B,C,D) of per-stream
* RSSI / SNR / EVM on a distinct <devourer-rxpath> line. Opt-in and
* separate so the canonical two-path <devourer-stream>/<devourer-body>
* format its regex consumers key on stays untouched. Paths C/D are
* non-zero only on the 8814AU (4T4R); on 8812/8821 they read 0. Consumed
* by tests/antenna_decorrelation.py to measure inter-chain envelope
* correlation and realised diversity gain (spatial-diversity axis). */
static const bool rxpath_out =
std::getenv("DEVOURER_RX_ALLPATHS") != nullptr;
if (rxpath_out && (!corrupted || keep_corrupted)) {
printf("<devourer-rxpath>seq=%u rssi=%d,%d,%d,%d snr=%d,%d,%d,%d "
"evm=%d,%d,%d,%d\n",
packet.RxAtrib.seq_num, packet.RxAtrib.rssi[0],
packet.RxAtrib.rssi[1], packet.RxAtrib.rssi[2],
packet.RxAtrib.rssi[3], packet.RxAtrib.snr[0],
packet.RxAtrib.snr[1], packet.RxAtrib.snr[2],
packet.RxAtrib.snr[3], packet.RxAtrib.evm[0],
packet.RxAtrib.evm[1], packet.RxAtrib.evm[2],
packet.RxAtrib.evm[3]);
fflush(stdout);
}
if (stream_out && (!corrupted || keep_corrupted)) {
/* Per-stream phy soft metrics (RSSI / EVM / SNR for paths A,B; on
* 8814AU paths C,D would also be non-zero but we surface only A,B
Expand Down Expand Up @@ -363,7 +384,44 @@ int main() {
logger->info("DEVOURER_VID={:04x} (overriding default VID)", target_vid);
}
libusb_device_handle *dev_handle = nullptr;

/* DEVOURER_USB_BUS (+ optional DEVOURER_USB_PORT) select a specific device by
* USB topology when several share one VID:PID and even the serial — e.g. two
* RTL8814AU dongles (CF-938AC vs CF-960AC) that enumerate identically, so only
* the bus/port tells them apart. DEVOURER_USB_PORT is the dotted libusb port
* path (as in sysfs `devpath` / `lsusb -t`, e.g. "2.3.2"). When bus is unset,
* the VID:PID open loop below runs as before. */
if (const char *bus_env = std::getenv("DEVOURER_USB_BUS")) {
const auto want_bus = static_cast<uint8_t>(std::strtoul(bus_env, nullptr, 0));
const char *port_env = std::getenv("DEVOURER_USB_PORT");
libusb_device **list = nullptr;
ssize_t n = libusb_get_device_list(ctx, &list);
for (ssize_t i = 0; i < n && dev_handle == NULL; ++i) {
libusb_device_descriptor dd{};
if (libusb_get_device_descriptor(list[i], &dd) != 0) continue;
if (dd.idVendor != target_vid) continue;
if (target_pid != 0 && dd.idProduct != target_pid) continue;
if (libusb_get_bus_number(list[i]) != want_bus) continue;
if (port_env != nullptr) {
uint8_t ports[8];
int pc = libusb_get_port_numbers(list[i], ports, sizeof(ports));
std::string path;
for (int p = 0; p < pc; ++p)
path += (path.empty() ? "" : ".") + std::to_string(ports[p]);
if (path != port_env) continue;
}
if (libusb_open(list[i], &dev_handle) == 0)
logger->info("Opened device {:04x}:{:04x} on bus {} port {}", dd.idVendor,
dd.idProduct, want_bus, port_env ? port_env : "(any)");
}
if (list != nullptr) libusb_free_device_list(list, 1);
if (dev_handle == NULL)
logger->error("DEVOURER_USB_BUS={} PORT={} matched no device", want_bus,
port_env ? port_env : "(any)");
}

for (uint16_t pid : kRealtekProductIds) {
if (dev_handle != NULL) break;
if (target_pid != 0 && pid != target_pid) continue;
dev_handle = libusb_open_device_with_vid_pid(ctx, target_vid, pid);
if (dev_handle != NULL) {
Expand Down
188 changes: 188 additions & 0 deletions docs/effective-branches-rotation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# Measuring effective diversity branches by rotation

A multi-chain adapter's spec sheet counts **RF chains** (2T2R, 4T4R). What a
diversity receiver actually gets is **effective branches** — the number of chains
whose fading is independent enough to add diversity. Those are not the same
number. Two adapters built on identical 4T4R silicon can deliver very different
effective diversity if one wires four decorrelated antennas and the other feeds
four chains from two antennas. This document is a lab procedure for finding the
truth experimentally, using nothing but a second adapter and your hands to rotate
the device under test.

It is written as an instruction: the concept first, then the method, then how to
run it, read it, and not fool yourself.

## Why chain count is not branch count

Diversity works because independent antennas see independent fades — when one is
in a null, another usually is not, so combining them (selection or
maximal-ratio) fills the nulls. The benefit collapses as the antennas' fading
becomes **correlated**: two co-located, same-polarisation antennas ride the same
fades and combining them buys almost nothing. So the quantity that matters is the
**envelope correlation** between chains, and from it the **effective branch
count**:

- **Envelope correlation** ρ — the correlation of the chains' signal *amplitude*
(not dB) over a fading channel. ρ near 0 = independent; ρ near 1 = redundant.
The classic rule of thumb is that diversity is still useful up to ρ ≈ 0.7.
- **Effective branches** N_eff — a single number summarising the whole
correlation matrix as "how many independent branches is this really." It is the
participation ratio of the correlation matrix: N chains that are mutually
uncorrelated give N_eff = N; N chains that all ride the same fade give
N_eff = 1. A 4-chain adapter whose chains pair up gives something in between.

The array/combining gain a real receiver extracts tracks N_eff, not the chain
count on the box.

## Why a static bench cannot measure it

Envelope correlation is only defined over a channel that **fades** — that varies
in time. A transmitter and receiver sitting still on a bench a few inches apart
present a *static* channel: the per-chain levels barely move (fractions of a dB),
so any correlation you compute is correlation of measurement noise, and it means
nothing. This is the first trap, and the measurement tool refuses to report a
verdict when it detects it (it calls the run inconclusive).

To measure decorrelation you must make the channel move. Three ways, cheapest
first:

1. **Rotation** — physically rotate and tilt the receiving adapter through many
orientations during the capture. As it turns, each antenna's radiation pattern
and polarisation sweep against the fixed transmitter, so every chain traces its
own level curve. Antennas that are genuinely different trace *different* curves
(low ρ); antennas that share a feed or point the same way trace the *same*
curve (high ρ). Rotation is doable from one desk in a minute.
2. **Relocation into multipath** — put the transmitter in another room or on a
balcony so the link becomes non-line-of-sight through walls and reflections.
Ambient movement then fades it. This is the more deployment-representative
channel.
3. **A mobile link** — the real thing: one end actually moving, as in flight.

Rotation measures **pattern/orientation decorrelation** under strong line of
sight. That is a *proxy* for the multipath envelope correlation a deployed link
sees — not identical to it, but it captures the property that dominates whether an
adapter's chains are redundant: do the antennas respond differently to the same
incoming wave. The *relative* comparison between two adapters on the same bench is
robust; the absolute ρ shifts in a real multipath field. Treat rotation as the
quick screen and relocation/mobility as the confirmation.

## The method

The receiver only needs a controlled, steady signal to measure against, and a way
to report each chain's level per frame:

1. **A controlled beacon transmitter.** A second adapter continuously injects a
known frame (a fixed source address) on the test channel. Using our own beacon
— rather than ambient traffic — means every measured frame comes from one
steady source, so the only thing varying is the receiver's channel, which is
exactly what you want to isolate.
2. **Per-chain metrics from the receiver.** The receiver reports each RF chain's
received level (and SNR/EVM) for every beacon frame. Over the capture this
builds a per-chain time series.
3. **Rotate the receiver** continuously through the whole capture window — full
turns plus tilts and flips, not a gentle quarter-turn. Vigour matters (see
pitfalls).
4. **Compute** the pairwise envelope correlation matrix and the effective branch
count, plus the combining gain the chains would have delivered.

### Reading the numbers

- **Per-chain level spread.** First sanity check: did the channel actually move?
If the strongest chain's spread is tiny, the rotation was insufficient or the
link too static — the result is inconclusive, full stop. Rotate harder or add
multipath.
- **Correlation matrix.** Look for structure. Chains that pair up at high |ρ| are
sharing an antenna or pointing together. Both positive and negative correlation
count as dependence (a chain that always goes *down* when another goes *up* is
not independent).
- **Effective branches N_eff.** The headline number. Close to the chain count =
the adapter delivers its full diversity order; well below it = some chains are
redundant.
- **Combining gain.** Selection- and maximal-ratio-combining gain over the best
single chain, reported at the median and at the low-outage tail (the 10 % and
1 % worst moments). Diversity shows up most at the tail — a well-decorrelated
multi-chain adapter buys many dB exactly when a single chain is deepest in a
fade, which is the moment a video link drops.

## Running it

The receiver's per-chain emission is opt-in (it does not disturb the normal
output): the RX demo publishes an all-chains line per beacon frame when asked,
and the analyser turns a capture into the report above. A capture is only valid
if the operator rotates the adapter for its full duration.

- **One adapter, one shot.** `tests/run_antenna_decorrelation.sh` builds
everything, brings up the beacon transmitter (and waits for it to actually
start injecting before capturing — a dead beacon window is a common way to get
an empty capture), then records a rotation window and prints the report. Its
rotation mode announces a short countdown so you can start turning the adapter.
- **Comparing several identical adapters.** `tests/compare_8814_decorrelation.sh`
finds every matching adapter and measures each in turn against one beacon, so
the only variable between runs is the antenna front-end. When two adapters share
the same USB identity (same vendor/product/**serial**), it selects between them
by USB topology (bus/port) rather than identity.
- **The analysis alone.** `tests/antenna_decorrelation.py` runs on a saved
capture, and has a hardware-independent self-test that validates the correlation
estimator, the effective-branch metric, and the combining math against
synthesised correlated fading — run that first if you change the tool.

The metric to capture is the received level; SNR and EVM are also emitted if you
prefer to correlate on those.

## Pitfalls (each one has bitten this measurement)

- **Gentle rotation lies.** A slow quarter-turn produces smooth, *correlated*
level sweeps on all chains and reports high correlation — the opposite of the
truth. Vigorous full-turn-plus-flip rotation explores enough orientations to
reveal the real (low) correlation of good antennas. Only trust a capture whose
per-chain spread is well above the tool's fading floor; if it is marginal,
rotate harder and re-run.
- **A static capture is not a measurement.** If you forget to rotate, the tool
will say inconclusive. Believe it.
- **A pinned/railed chain carries no information.** Under a strong static
line-of-sight signal, a chain's reported level can sit on an automatic-gain rail
(a constant value) and look dead — then vary normally once the adapter moves.
This is exactly why rotation is required, not optional. The tool flags a
near-constant chain so you don't mistake an AGC rail for a real reading.
- **The beacon must actually be on air.** Both ends take several seconds to
initialise, and a flaky transmitter can fail to claim on the first try. Confirm
the beacon is injecting before trusting a zero-frame capture as "no signal."
- **Identical adapters need topology selection.** Two dongles with the same
USB identity down to the serial can only be told apart by which bus/port they
are on — select the intended one explicitly, or you will measure the same device
twice.
- **Rotation ≠ multipath.** The rotation result is orientation/pattern
decorrelation under line of sight. It is the right quick screen and the right
*relative* comparator, but confirm an important conclusion with a
non-line-of-sight or mobile capture before treating an absolute correlation
figure as the deployed one.

## Worked example: two 4T4R dongles, identical silicon

Two USB adapters built on the same 4T4R chip, differing only in antenna
front-end, measured on one bench against one beacon on the 2.4 GHz test channel,
each rotated vigorously through a capture:

| Adapter | Antennas | Worst \|ρ\| | Effective branches N_eff | MRC gain at 1 % outage | Verdict |
|---|---|---|---|---|---|
| 4-internal-antenna puck | 4 on-PCB internal | 0.19 | **3.8** of 4 | ~14 dB | well decorrelated |
| 2-external-dipole stick | 2 external dipoles | 0.64 | **2.6** of 4 | ~11 dB | partially correlated |

The stick drives four RF chains from only two physical antennas, so two of its
chains are strongly coupled and its effective branch count sits near its antenna
count, not its chain count. The puck's four genuine antennas decorrelate on every
pair and it delivers close to its full four-branch diversity — and materially more
combining gain at the low-outage tail, which is where a long-range link lives.
Same silicon, different truth. That difference is invisible on the spec sheet and
obvious in five minutes of rotation.

## Toward the gold standard

Rotation answers "are these chains independent enough to matter" cheaply and
comparatively. To turn a relative screen into a deployment number, repeat the
capture with the transmitter relocated into genuine non-line-of-sight multipath
(another room, a balcony, across a floor) and, ultimately, with one end in
motion. The tooling is identical — only the stimulus changes; the beacon can run
on a second host anywhere the receiver can still hear it. Expect the *ordering*
between adapters to hold and the absolute correlation to rise toward the values a
flying link actually experiences.
Loading
Loading