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 @@ -147,7 +147,7 @@ jobs:
cmake --build build --target
WiFiDriver StreamTxDemo StreamDuplexDemo StreamStdinSelftest
ToneMaskSelftest BfReportDecodeSelftest SweepSpecSelftest
TxPowerQuantSelftest
TxPowerQuantSelftest LinkHealthSelftest

- name: Test
working-directory: build
Expand Down
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,19 @@ Both `WiFiDriverDemo` and `WiFiDriverTxDemo` honour:
- `DEVOURER_THERMAL_WARN_DELTA=N` — thermal-units-above-baseline threshold at
which a one-shot `warn` fires (default `15`); re-arms once the chip cools
back below it.
- `DEVOURER_LINKHEALTH=1` — (`WiFiDriverDemo` RX, needs `DEVOURER_RX_ENERGY_MS`)
emit a `<devourer-linkhealth>` verdict line per energy window: the RX sensor
tuple classified into a plain-language cause + fix (`src/LinkHealth.h`). The
point is to tell a **near-field saturation** problem (strong RSSI + poor EVM —
*back OFF* TX power, add attenuation/distance) apart from a genuine weak link
(*add* power) so a user isn't chasing the wrong remedy — EVM, not SNR, is the
saturation tell (SNR looks fine while the constellation collapses). Verdicts:
`SATURATED` / `INTERFERENCE` / `WEAK` / `MARGINAL` / `HEALTHY` / `NO_SIGNAL`.
Uses `rssi_max` (window peak) as the strength signal since near-field
saturation drags the mean down. Thresholds calibrated on-air
(`tests/saturation_knee_sweep.sh`, `tests/j3_dig_penalty_sweep.sh`), unit-
guarded (`tests/link_health_selftest.cpp`), SAT-vs-HEALTHY verified on-air
(`tests/link_health_onair.sh`). See **`docs/bench-testing-near-field.md`**.

`WiFiDriverTxDemo` selects the on-air TX mode with a single env var that it
parses into a `devourer::TxMode` and hands to `RtlJaguarDevice::SetTxMode`
Expand Down
13 changes: 13 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ add_library(WiFiDriver
src/TxPower.cpp
src/TxPower.h
src/ThermalStatus.h
src/LinkHealth.cpp
src/LinkHealth.h
src/IRtlDevice.h
src/SignalStop.cpp
src/SignalStop.h
Expand Down Expand Up @@ -339,6 +341,17 @@ target_link_libraries(TxPowerQuantSelftest PRIVATE WiFiDriver)

add_test(NAME txpower_quant_math COMMAND TxPowerQuantSelftest)

# Headless guard for the link-health classifier (src/LinkHealth.h) — the
# sensor-tuple -> verdict mapping behind <devourer-linkhealth>, with cases
# drawn from real on-air data (the saturation-knee + AWGN-interference sweeps).
# A misclassification fails `ctest` instead of only surfacing as bad advice.
add_executable(LinkHealthSelftest
tests/link_health_selftest.cpp
)
target_link_libraries(LinkHealthSelftest PRIVATE WiFiDriver)

add_test(NAME link_health_classify COMMAND LinkHealthSelftest)

# 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
45 changes: 45 additions & 0 deletions demo/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include <libusb.h>

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

/* DEVOURER_LINKHEALTH=1 — emit a <devourer-linkhealth> verdict line alongside
* each <devourer-energy> window (src/LinkHealth.h): the sensor tuple classified
* into a plain-language cause + fix. Rides the DEVOURER_RX_ENERGY_MS cadence, so
* that must be set too (a linkhealth verdict needs the same window snapshot). */
static const bool g_linkhealth = []() -> bool {
const char *e = std::getenv("DEVOURER_LINKHEALTH");
return e && *e && std::strcmp(e, "0") != 0;
}();

/* 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
Expand Down Expand Up @@ -727,6 +737,41 @@ int main() {
evm_mean);
fflush(stdout);
emit_nhm(e, -1);
/* DEVOURER_LINKHEALTH=1 — classify the window into a plain-language
* verdict + fix (src/LinkHealth.h). Rides the energy cadence and the
* same sensor snapshot; the whole point is to tell a near-field
* saturation problem (strong RSSI, dirty EVM — back OFF power) apart
* from a weak link (add power) so a user isn't chasing the wrong
* remedy. IGI rails passed as the union J1/J2 floor (the saturation
* corroborator); J3 has no DIG so its IGI is a static hint only. */
if (g_linkhealth) {
devourer::LinkHealthInput in;
in.frames = agg.n;
/* Strength = window PEAK (near-field saturation drags the mean down;
* see LinkHealth.h). */
in.rssi_raw = agg.n ? agg.rssi_max : 0;
in.snr_raw = snr_mean;
in.evm_raw = evm_mean;
in.evm_valid = agg.evm_n > 0;
in.energy_valid = e.valid_fa;
in.fa_ofdm = e.fa_ofdm;
in.cca_ofdm = e.cca_ofdm;
in.igi_valid = e.valid_igi;
in.igi = e.igi;
in.igi_min = 0x1c; /* J1/J2 DIG floor — the saturation hint */
in.igi_max = 0x7f; /* J3 ceiling — never a false 'weak' rail */
devourer::LinkHealthVerdict h = devourer::classify_link_health(in);
char evmb[16];
if (in.evm_valid) std::snprintf(evmb, 16, "%.1f", h.evm_db);
else std::snprintf(evmb, 16, "-");
printf("<devourer-linkhealth>verdict=%s rssi_dbm=%d snr_db=%.1f "
"evm_db=%s frames=%u fa_ofdm=%s igi=%s%s%s cause=\"%s\" "
"fix=\"%s\"\n",
h.label, h.rssi_dbm, h.snr_db, evmb, agg.n, fao, igi,
h.igi_at_floor ? " igi_floor=1" : "",
h.igi_at_ceiling ? " igi_ceil=1" : "", h.cause, h.fix);
fflush(stdout);
}
nap(g_rx_energy_ms);
}
});
Expand Down
99 changes: 99 additions & 0 deletions docs/bench-testing-near-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Bench testing without lying to yourself: near-field saturation

The most common way a bench test misleads you: you put the TX and RX inches (or
a few feet) apart, run high power, and the link is *worse* than it was at range
— low throughput, dropped frames, garbage video — and you start blaming the NIC,
the antennas, or interference. Usually none of those. The signal is simply **too
strong for the receiver**, and adding power or swapping antennas makes it worse.

Two mechanisms, both power problems that masquerade as sensitivity problems:

- **Front-end saturation.** The LNA and ADC clip. The chip's AGC drops its gain
to the floor and *still* can't keep the signal in the linear range, so the
constellation smears — even though the raw signal level is enormous.
- **Multipath self-jamming.** Your own strong signal bounces off nearby walls and
arrives delayed. To the demodulator those delayed copies are in-band noise.
Past some power level, adding TX power adds as much reflected noise as signal,
and the link jams itself. This is why a link that is fine outdoors falls apart
in a room at ten feet.

## The tell: EVM, not SNR

The counterintuitive part — and the reason people miss it — is that **SNR can
look perfectly healthy while the link is saturating.** Measured on this bench,
sweeping an 8812AU's TX power from low to full while a second adapter reported
per-frame metrics (`tests/saturation_knee_sweep.sh`):

| TX power | RSSI (raw) | SNR | EVM |
|---|---|---|---|
| low | 50 | 18 dB | −28 dB (clean) |
| **knee** | ~60 | 18 dB | **−28 dB (best)** |
| high | 70 | 18 dB | −20 dB (degrading) |
| full | 73 | **18 dB (unchanged)** | **−13 dB (collapsed)** |

SNR sat flat at 18 dB across the whole sweep and told you nothing. **EVM
improved as power rose, then reversed at the saturation knee** — the moment more
signal started making the constellation *worse*. That turnover is the signature.

## Is 25 mW too much next to each other?

Yes. The numbers, at 5.8 GHz:

- Free-space loss is only ~28 dB at 10 cm, ~57 dB at 3 m (10 ft).
- 25 mW = +14 dBm. At 10 cm the receiver sees **+14 − 28 = −14 dBm**.
- The RTL88xx front end starts compressing around **−10 to −20 dBm** input.

So 25 mW at 10 cm lands you squarely *in* compression — which is exactly the
"RSSI −10, link dead" report. These receivers are linear and happy around **−40
to −70 dBm** at the input. To bench two adapters safely you want **≥ 40 dB of
loss between them**: drop TX to ~1 mW (0 dBm), add a 30–40 dB attenuator, or
both. Ten feet in a reflective room is past hard clipping but squarely in the
multipath-desense regime.

## How to bench without the trap

1. **Back the power off.** With the runtime TX-power API (`src/TxPower.h`) this
is one call — no low-power firmware needed:
`dev->SetTxPowerOffsetQdb(-80)` drops 20 dB (25 mW → ~0.25 mW). Sweep the
offset down until EVM stops improving; that knee is your linear operating
point. `tests/saturation_knee_sweep.sh` does exactly this and prints it.
2. **Add loss instead of distance** when you can't move things apart: a
30–40 dB SMA attenuator on the conducted path, or just physical separation
and cross-polarised antennas.
3. **Watch EVM, not SNR.** A strong RSSI with poor EVM is the saturation
fingerprint. `DEVOURER_RX_ENERGY_MS=500 DEVOURER_LINKHEALTH=1` on
`WiFiDriverDemo` classifies each window and says so in plain language:

```
<devourer-linkhealth>verdict=SATURATED rssi_dbm=-33 snr_db=19.5 evm_db=-22.5
cause="strong RSSI but poor EVM — receiver front-end overload and/or the
strong signal self-jamming via reflections (near-field). SNR alone can
look fine here"
fix="REDUCE TX power (SetTxPowerOffsetQdb, e.g. -40..-80 qdB), add an
attenuator, or increase distance — do NOT add power/antenna"
```

4. **Only trust a "weak link" verdict at range.** If the link doctor says
`WEAK` (low RSSI, low SNR, AGC wide open) *then* more power or a better
antenna is the answer. If it says `SATURATED`, the answer is the opposite —
and no amount of antenna tuning will fix a receiver that is clipping.

## The link doctor (`<devourer-linkhealth>`)

`src/LinkHealth.h` maps the RX sensor tuple to one of: `SATURATED`,
`INTERFERENCE`, `WEAK`, `MARGINAL`, `HEALTHY`, `NO_SIGNAL`, each with a
one-line cause and the fix. The discriminators, all from telemetry devourer
already exposes:

- **SATURATED** — strong peak RSSI **and** poor EVM (SNR can look fine). Back
off power / attenuate / add distance.
- **INTERFERENCE** — not-strong signal, degraded, **and** a high false-alarm
rate: external energy raising the floor. Hop channels.
- **WEAK** — low RSSI, low SNR, AGC at its ceiling. Genuine range limit: more
power / antenna helps here.
- **HEALTHY** — good SNR + EVM in the linear range.

It rides the `DEVOURER_RX_ENERGY_MS` cadence (so set both). The thresholds are
calibrated from on-air measurement (the sweep above and the AWGN sweep in
`tests/j3_dig_penalty_sweep.sh`) and unit-guarded in
`tests/link_health_selftest.cpp`.
105 changes: 105 additions & 0 deletions src/LinkHealth.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#include "LinkHealth.h"

namespace devourer {

LinkHealthVerdict classify_link_health(const LinkHealthInput &in,
const LinkHealthThresholds &th) {
LinkHealthVerdict v;
v.rssi_dbm = in.rssi_raw - 110;
v.snr_db = in.snr_raw / 2.0;
v.evm_db = in.evm_valid ? in.evm_raw / 2.0 : 0.0;
if (in.igi_valid) {
v.igi_at_floor = in.igi <= in.igi_min;
v.igi_at_ceiling = in.igi >= in.igi_max;
}

if (in.frames == 0) {
v.verdict = LinkVerdict::NoSignal;
v.label = "NO_SIGNAL";
v.cause = "no frames decoded this window";
v.fix = "check the TX is up, the channel/bandwidth matches, and the "
"canonical SA is being sent";
return v;
}

/* Signal-strength band and constellation cleanliness. EVM is the primary
* discriminator (SNR misses saturation); RSSI splits strong-dirty from
* weak-dirty. When EVM is absent (CCK-only stream), fall back to SNR. */
const bool strong = in.rssi_raw >= th.rssi_strong;
const bool weak = in.rssi_raw <= th.rssi_weak;
const bool snr_poor = in.snr_raw < th.snr_lo;
const bool snr_good = in.snr_raw >= th.snr_good;
const bool evm_poor = in.evm_valid && in.evm_raw > th.evm_poor;
const bool evm_good = !in.evm_valid || in.evm_raw < th.evm_good;
const bool dirty = evm_poor || (!in.evm_valid && snr_poor);
const bool noisy = in.energy_valid && in.fa_ofdm > th.fa_high;

/* Strong signal but a dirty constellation = the near-field failure the
* classifier exists for: front-end saturation and/or the strong signal
* self-jamming via wall reflections. IGI pinned at its floor (the AGC has
* already backed gain all the way off and still can't cope) corroborates. */
if (strong && dirty) {
v.verdict = LinkVerdict::Saturated;
v.label = "SATURATED";
v.cause = "strong RSSI but poor EVM — receiver front-end overload and/or "
"the strong signal self-jamming via reflections (near-field). "
"SNR alone can look fine here";
v.fix = "REDUCE TX power (SetTxPowerOffsetQdb, e.g. -40..-80 qdB), add an "
"attenuator, or increase distance — do NOT add power/antenna";
return v;
}

/* Not strong, but dirty AND the false-alarm rate is up = something other
* than your own signal is raising the floor: external / co-channel. */
if (!strong && dirty && noisy) {
v.verdict = LinkVerdict::Interference;
v.label = "INTERFERENCE";
v.cause = "elevated false-alarm rate with a degraded constellation while "
"the wanted signal is not strong — external / co-channel energy "
"raising the noise floor";
v.fix = "change channel (FastRetune / hop away from the interferer); a "
"narrowband notch (DEVOURER_RX_NBI) only helps an in-band spur";
return v;
}

/* Weak signal, poor SNR, AGC wide open = a genuine range/sensitivity limit. */
if (weak && (snr_poor || !snr_good)) {
v.verdict = LinkVerdict::Weak;
v.label = "WEAK";
v.cause = "low RSSI and low SNR — genuine range / sensitivity limit "
"(this is where more power or a better antenna actually helps)";
v.fix = "raise TX power, improve antenna gain/alignment, or reduce "
"distance; consider a narrowband re-clock for link budget";
return v;
}

/* Decodes cleanly with comfortable margin. */
if (snr_good && evm_good && !strong) {
v.verdict = LinkVerdict::Healthy;
v.label = "HEALTHY";
v.cause = "good SNR and EVM with RSSI in the linear range";
v.fix = "none — link is in its comfortable operating region";
return v;
}
/* A strong-but-clean link is healthy too, but flag that it is near the top
* of the range (one step from the saturation regime on a near-field bench). */
if (snr_good && evm_good && strong) {
v.verdict = LinkVerdict::Healthy;
v.label = "HEALTHY";
v.cause = "good SNR and EVM, but RSSI is high — near the top of the linear "
"range; a little more power or less distance risks saturation";
v.fix = "fine as-is; leave headroom before adding TX power on a short link";
return v;
}

/* Everything else: decoding but without a comfortable margin. */
v.verdict = LinkVerdict::Marginal;
v.label = "MARGINAL";
v.cause = "decoding, but SNR/EVM is below a comfortable margin and no single "
"cause dominates";
v.fix = "watch the trend; if RSSI is high, try backing off power first "
"(cheap to test), otherwise a small power/antenna improvement";
return v;
}

} // namespace devourer
Loading
Loading