diff --git a/.github/workflows/cmake-multi-platform.yml b/.github/workflows/cmake-multi-platform.yml index 42ae18e..382d206 100644 --- a/.github/workflows/cmake-multi-platform.yml +++ b/.github/workflows/cmake-multi-platform.yml @@ -147,7 +147,7 @@ jobs: cmake --build build --target WiFiDriver StreamTxDemo StreamDuplexDemo StreamStdinSelftest ToneMaskSelftest BfReportDecodeSelftest SweepSpecSelftest - TxPowerQuantSelftest + TxPowerQuantSelftest LinkHealthSelftest - name: Test working-directory: build diff --git a/CLAUDE.md b/CLAUDE.md index 5dc8339..96dff1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` 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` diff --git a/CMakeLists.txt b/CMakeLists.txt index 9adf984..52f1ad9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 @@ -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 , 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 diff --git a/demo/main.cpp b/demo/main.cpp index 1a01879..24fbf65 100644 --- a/demo/main.cpp +++ b/demo/main.cpp @@ -12,6 +12,7 @@ #include #include "BfReportDetect.h" +#include "LinkHealth.h" #include "RxPacket.h" #include "SweepSpec.h" #if defined(DEVOURER_HAVE_JAGUAR1) @@ -143,6 +144,15 @@ static const uint32_t g_rx_energy_ms = []() -> uint32_t { return (e && *e) ? static_cast(std::strtoul(e, nullptr, 0)) : 0; }(); +/* DEVOURER_LINKHEALTH=1 — emit a verdict line alongside + * each 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 @@ -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("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); } }); diff --git a/docs/bench-testing-near-field.md b/docs/bench-testing-near-field.md new file mode 100644 index 0000000..880899c --- /dev/null +++ b/docs/bench-testing-near-field.md @@ -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: + + ``` + 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 (``) + +`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`. diff --git a/src/LinkHealth.cpp b/src/LinkHealth.cpp new file mode 100644 index 0000000..65698eb --- /dev/null +++ b/src/LinkHealth.cpp @@ -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 diff --git a/src/LinkHealth.h b/src/LinkHealth.h new file mode 100644 index 0000000..b08d829 --- /dev/null +++ b/src/LinkHealth.h @@ -0,0 +1,101 @@ +/* Link-health classifier — the "why is my link bad" interpreter. + * + * Devourer exposes rich RX telemetry (per-frame RSSI/SNR/EVM, frame-free + * FA/CCA/IGI), but a user staring at raw numbers can't tell a near-field + * saturation problem from a weak link from external interference — and the + * instinct ("signal's bad, add power / swap the antenna") is often exactly + * backwards. This maps the sensor tuple to a plain-language verdict + the fix. + * + * The load-bearing insight, measured on-air (tests/saturation_knee_sweep.sh): + * as TX power rises on a near-field link, RSSI climbs but EVM *improves then + * reverses* at the front-end saturation knee — SNR misses it entirely (it sat + * flat at 18 dB across the whole sweep while EVM went from -28 dB to -13 dB). + * So EVM is the discriminator for "strong but dirty" (overload / multipath + * self-jam), and RSSI splits that from "weak and dirty" (noise / interference). + * + * Thresholds are raw devourer units, calibrated from that sweep + the AWGN + * interference sweep (tests/j3_dig_penalty_sweep.sh): + * RSSI raw PWDB byte, dBm ~= raw - 110; rises with power, ceilings ~73. + * SNR signed half-dB (dB = raw/2); higher better. + * EVM signed half-dB (dB = raw/2); LOWER (more negative) better; 0 = none. + * FA OFDM false alarms per window; quiet ~ tens, interfered ~ hundreds. + */ +#ifndef DEVOURER_LINK_HEALTH_H +#define DEVOURER_LINK_HEALTH_H + +#include + +namespace devourer { + +enum class LinkVerdict { + NoSignal, /* no frames decoded this window */ + Saturated, /* strong RSSI + dirty EVM: near-field overload / self-jam */ + Interference, /* not-strong + dirty + high false-alarm: external / raised floor */ + Weak, /* low RSSI + low SNR: genuine range/sensitivity limit */ + Marginal, /* decodes, but SNR/EVM below a comfortable margin */ + Healthy, /* strong-enough RSSI, good SNR + EVM, in the linear range */ +}; + +/* Sensor snapshot over one window. Frame metrics are the path-A medians the + * demo already aggregates; the *_valid flags let a caller pass only what it + * has (frame-free energy and IGI are optional). Raw devourer units. */ +struct LinkHealthInput { + uint32_t frames = 0; /* frames decoded in the window */ + /* rssi_raw is the STRENGTH signal — pass the window PEAK (rssi_max), not the + * mean: near-field saturation trashes a fraction of frames to low apparent + * power, dragging the mean down while the peak stays pegged near the PWDB + * ceiling (measured: full-power near-field rssi_max ~81, backed-off ~77). */ + int rssi_raw = 0; /* path-A window peak PWDB (dBm ~= raw - 110) */ + int snr_raw = 0; /* path-A mean SNR, half-dB */ + int evm_raw = 0; /* path-A mean EVM, half-dB (lower better) */ + bool evm_valid = false; + + bool energy_valid = false; /* fa_ofdm / cca_ofdm populated */ + uint32_t fa_ofdm = 0; + uint32_t cca_ofdm = 0; + + bool igi_valid = false; /* current IGI + its family rails (rail = a hint) */ + int igi = 0; + int igi_min = 0; + int igi_max = 0; +}; + +struct LinkHealthVerdict { + LinkVerdict verdict = LinkVerdict::NoSignal; + const char *label = "NO_SIGNAL"; /* short tag for the marker line */ + const char *cause = ""; /* one-line mechanism */ + const char *fix = ""; /* one-line remedy */ + /* Converted, for display alongside the raw values. */ + int rssi_dbm = 0; + double snr_db = 0.0; + double evm_db = 0.0; /* 0 when evm_valid is false */ + bool igi_at_floor = false; + bool igi_at_ceiling = false; +}; + +/* Threshold set (raw units), exposed so a test/tool can reference the exact + * boundaries the measurement calibrated. */ +struct LinkHealthThresholds { + int rssi_strong = 66; /* >= this = strong signal (near-field regime) */ + int rssi_weak = 38; /* <= this = weak (~ -72 dBm) */ + /* EVM boundaries calibrated to the demo's window-aggregate (not the tight + * sweep median): a near-field FULL-power link reads evm_mean ~ -40..-45, + * the same geometry backed off to the EVM knee reads ~-51..-52. At strong + * signal, EVM should be excellent (-28..-30 dB, raw -56..-60), so anything + * worse than ~-23 dB (raw -47) at strong signal is degraded-for-its-strength + * = the saturation / self-jam tell. */ + int evm_poor = -47; /* worse (greater) than this = dirty constellation */ + int evm_good = -49; /* better (less) than this = clean */ + int snr_lo = 16; /* < this (8 dB) = poor SNR */ + int snr_good = 30; /* >= this (15 dB) = comfortable */ + uint32_t fa_high = 300; /* OFDM FA/window above this = a noisy channel */ +}; + +/* Classify. Pure function of the snapshot + thresholds (defaulted from the + * on-air calibration). No I/O — unit-tested in tests/link_health_selftest.cpp. */ +LinkHealthVerdict classify_link_health(const LinkHealthInput &in, + const LinkHealthThresholds &th = {}); + +} // namespace devourer + +#endif /* DEVOURER_LINK_HEALTH_H */ diff --git a/tests/link_health_onair.sh b/tests/link_health_onair.sh new file mode 100755 index 0000000..95e98e4 --- /dev/null +++ b/tests/link_health_onair.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Hardware validation for the link-health classifier (). +# The classifier's core job is the near-field story from the brainstorm: tell +# "strong but self-jamming, BACK OFF power" apart from a clean link. That is +# the A/B this validates on-air, at a fixed short bench distance: +# +# SATURATED 8812AU TX at FULL power (index 63): rssi_max pegged at the PWDB +# ceiling + EVM degraded (~-42) — the near-field overload / self-jam. +# HEALTHY same geometry, TX backed off to the EVM knee (~22): same strong +# RSSI, clean EVM (~-51). +# +# INTERFERENCE and WEAK are real classifier outputs but can't be cleanly +# reproduced at bench range — a strong direct path inches away dominates any +# injected noise (the classifier then correctly reports SATURATED). Those +# verdicts are validated synthetically in tests/link_health_selftest.cpp with +# cases calibrated from the AWGN sweep (tests/j3_dig_penalty_sweep.sh). A third +# regime here injects B210 AWGN as an informational check only (no pass/fail). +# +# Ground = a second devourer part running WiFiDriverDemo with +# DEVOURER_RX_ENERGY_MS + DEVOURER_LINKHEALTH. +# +# Usage: sudo -v && tests/link_health_onair.sh [tx_pid] [ground_pid] +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${LINKHEALTH_OUT:-/tmp/devourer-linkhealth}" +CH="${CH:-36}" +TX_PID="${1:-0x8812}" TX_VID="${TX_VID:-0x0bda}" +GROUND_PID="${2:-0xc812}" GROUND_VID="${GROUND_VID:-0x0bda}" +PY="$ROOT/tests/.venv/bin/python"; { [ -x "$PY" ] && "$PY" -c 'import uhd' 2>/dev/null; } || PY=python3 +mkdir -p "$OUT" + +PASS=0; FAIL=0; SKIP=0 +pass() { echo " PASS: $*"; PASS=$((PASS+1)); } +fail() { echo " FAIL: $*"; FAIL=$((FAIL+1)); } +skip() { echo " SKIP: $*"; SKIP=$((SKIP+1)); } + +cleanup() { + pkill -x WiFiDriverTxDem 2>/dev/null || true + pkill -x WiFiDriverDemo 2>/dev/null || true + sudo -n pkill -f sdr_interferer 2>/dev/null || true + true +} +trap cleanup EXIT INT TERM +plugged() { lsusb -d "$(printf '%04x:%04x' "$2" "$1")" >/dev/null 2>&1; } +plugged "$TX_PID" "$TX_VID" || { echo "SKIP: TX $TX_PID not plugged"; exit 0; } +plugged "$GROUND_PID" "$GROUND_VID" || { echo "SKIP: ground $GROUND_PID not plugged"; exit 0; } + +echo "== building ==" +cmake --build "$ROOT/build" -j --target WiFiDriverDemo WiFiDriverTxDemo >/dev/null || exit 1 + +# Run a ground-RX window against a beacon at TX index $1 (+ optional interferer +# gain in $2); echo the MODAL verdict over the window. +run_regime() { # $1=tx_idx $2=jam_gain("" = none) $3=tag + local idx="$1" jam="$2" tag="$3" jampid="" + if [ -n "$jam" ]; then + sudo -n "$PY" "$ROOT/tests/sdr_interferer.py" --channel "$CH" \ + --tx-gain "$jam" --rate 20e6 --mode noise --secs 30 \ + >"$OUT/$tag-noise.log" 2>&1 & + jampid=$! + sleep 5 + fi + sudo -n env DEVOURER_PID="$GROUND_PID" DEVOURER_VID="$GROUND_VID" \ + DEVOURER_CHANNEL="$CH" DEVOURER_RX_ENERGY_MS=500 DEVOURER_LINKHEALTH=1 \ + timeout 22 "$ROOT/build/WiFiDriverDemo" >"$OUT/$tag.log" 2>&1 & + local gj=$! + sleep 6 + sudo -n env DEVOURER_PID="$TX_PID" DEVOURER_VID="$TX_VID" DEVOURER_CHANNEL="$CH" \ + DEVOURER_TX_RATE=MCS3 DEVOURER_TX_PWR="$idx" DEVOURER_TX_GAP_US=1500 \ + timeout 14 "$ROOT/build/WiFiDriverTxDemo" >/dev/null 2>&1 & + local tx=$! + wait "$tx" 2>/dev/null + sudo -n pkill -x WiFiDriverDemo 2>/dev/null; wait "$gj" 2>/dev/null + [ -n "$jampid" ] && { kill "$jampid" 2>/dev/null; sudo -n pkill -f sdr_interferer 2>/dev/null; } + sleep 2 + # Modal verdict across the window's linkhealth lines (skip the first, it can + # land during TX bring-up before frames arrive). + grep -oE "verdict=[A-Z_]+" "$OUT/$tag.log" \ + | sed 's/.*verdict=//' | tail -n +2 | sort | uniq -c | sort -rn | head -1 \ + | awk '{print $2" ("$1" windows)"}' +} + +echo "== regime 1: SATURATED (TX index 63, near-field, no attenuation) ==" +r1="$(run_regime 63 "" sat)" +echo " modal verdict: $r1" +case "$r1" in SATURATED*) pass "full-power near-field -> SATURATED" ;; + *) fail "expected SATURATED, got: $r1 (see $OUT/sat.log)" ;; esac + +echo "== regime 2: HEALTHY (TX index 22, backed off to the EVM knee) ==" +r2="$(run_regime 22 "" healthy)" +echo " modal verdict: $r2" +case "$r2" in HEALTHY*|MARGINAL*) pass "backed-off near-field -> $r2 (clean)" ;; + *) fail "expected HEALTHY/MARGINAL, got: $r2 (see $OUT/healthy.log)" ;; esac + +# Informational only: at bench range the strong direct path dominates injected +# noise, so this typically reads SATURATED (correct physics) rather than a clean +# INTERFERENCE — the INTERFERENCE verdict is validated synthetically in the +# selftest. Reported, never failed. +if [ "${SKIP_INTERF:-0}" != "1" ]; then + echo "== regime 3 (informational): B210 AWGN gain 74 + low TX ==" + r3="$(run_regime 14 74 interf)" + echo " modal verdict: $r3 (informational — bench range can't isolate interference)" +fi + +echo +echo "== link-health on-air: PASS=$PASS FAIL=$FAIL SKIP=$SKIP ==" +[ "$FAIL" -eq 0 ] diff --git a/tests/link_health_selftest.cpp b/tests/link_health_selftest.cpp new file mode 100644 index 0000000..491a847 --- /dev/null +++ b/tests/link_health_selftest.cpp @@ -0,0 +1,139 @@ +/* Headless guard for the link-health classifier (src/LinkHealth.h) — the + * sensor-tuple -> verdict mapping behind . The cases are + * drawn from real on-air data: the saturation-knee sweep + * (tests/saturation_knee_sweep.sh: clean EVM ~-56 at rssi 57, collapsed EVM + * ~-27 at rssi 72) and the AWGN interference sweep (tests/j3_dig_penalty_sweep: + * quiet FA ~1-30, interfered FA ~200-500). A misclassification fails `ctest` + * instead of only surfacing as bad on-air advice. */ +#include + +#include "LinkHealth.h" + +static int g_fail = 0; + +static const char *vname(devourer::LinkVerdict v) { + switch (v) { + case devourer::LinkVerdict::NoSignal: return "NoSignal"; + case devourer::LinkVerdict::Saturated: return "Saturated"; + case devourer::LinkVerdict::Interference: return "Interference"; + case devourer::LinkVerdict::Weak: return "Weak"; + case devourer::LinkVerdict::Marginal: return "Marginal"; + case devourer::LinkVerdict::Healthy: return "Healthy"; + } + return "?"; +} + +static void expect(const char *name, const devourer::LinkHealthInput &in, + devourer::LinkVerdict want) { + const devourer::LinkHealthVerdict got = devourer::classify_link_health(in); + if (got.verdict == want) + return; + ++g_fail; + std::printf("FAIL: %s -> %s, want %s\n", name, vname(got.verdict), + vname(want)); +} + +int main() { + using devourer::LinkHealthInput; + using V = devourer::LinkVerdict; + + /* No frames. */ + expect("no-signal", LinkHealthInput{}, V::NoSignal); + + /* Saturation knee (measured): top-of-ladder cell — strong RSSI 72, EVM + * collapsed to -27 (raw), SNR still "fine" at 36. The signature SNR misses. */ + { + LinkHealthInput in; + in.frames = 3000; + in.rssi_raw = 72; + in.snr_raw = 36; /* 18 dB — looks healthy */ + in.evm_raw = -27; + in.evm_valid = true; + expect("saturated-knee-top", in, V::Saturated); + } + /* Saturation corroborated by IGI pinned at the floor. */ + { + LinkHealthInput in; + in.frames = 2000; + in.rssi_raw = 70; + in.snr_raw = 34; + in.evm_raw = -31; + in.evm_valid = true; + in.igi_valid = true; + in.igi = 0x1c; + in.igi_min = 0x1c; + in.igi_max = 0x2a; + expect("saturated-igi-floor", in, V::Saturated); + } + + /* Clean strong link (measured: rssi 57, evm -56, snr 36) = healthy. */ + { + LinkHealthInput in; + in.frames = 3300; + in.rssi_raw = 57; + in.snr_raw = 36; + in.evm_raw = -56; + in.evm_valid = true; + expect("healthy-clean", in, V::Healthy); + } + + /* External interference: moderate RSSI, degraded EVM, and a false-alarm + * rate up in the hundreds (AWGN sweep). Not strong -> not saturation. */ + { + LinkHealthInput in; + in.frames = 800; + in.rssi_raw = 48; + in.snr_raw = 14; /* 7 dB */ + in.evm_raw = -20; + in.evm_valid = true; + in.energy_valid = true; + in.fa_ofdm = 380; + expect("interference-awgn", in, V::Interference); + } + + /* Weak far link: low RSSI, low SNR, AGC wide open. */ + { + LinkHealthInput in; + in.frames = 200; + in.rssi_raw = 30; /* ~ -80 dBm */ + in.snr_raw = 12; /* 6 dB */ + in.evm_raw = -18; + in.evm_valid = true; + in.igi_valid = true; + in.igi = 0x2a; + in.igi_min = 0x1c; + in.igi_max = 0x2a; + expect("weak-far", in, V::Weak); + } + + /* A degraded-but-not-strong, low-FA cell with no clear cause = marginal. */ + { + LinkHealthInput in; + in.frames = 1500; + in.rssi_raw = 50; + in.snr_raw = 22; /* 11 dB */ + in.evm_raw = -30; + in.evm_valid = true; + in.energy_valid = true; + in.fa_ofdm = 40; /* quiet — not interference */ + expect("marginal", in, V::Marginal); + } + + /* CCK-only stream (no EVM): classification falls back to SNR. Strong + poor + * SNR still reads as saturated. */ + { + LinkHealthInput in; + in.frames = 1000; + in.rssi_raw = 70; + in.snr_raw = 10; /* 5 dB, poor */ + in.evm_valid = false; + expect("saturated-cck-nosnr", in, V::Saturated); + } + + if (g_fail) { + std::printf("%d failure(s)\n", g_fail); + return 1; + } + std::printf("link-health selftest: all OK\n"); + return 0; +} diff --git a/tests/saturation_knee_sweep.sh b/tests/saturation_knee_sweep.sh new file mode 100755 index 0000000..85069d2 --- /dev/null +++ b/tests/saturation_knee_sweep.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Near-field saturation-knee sweep — the measurement behind the link-health +# classifier and the bench-testing guidance. Walks the TX-power flat index over +# its full range (the runtime knob DEVOURER_TX_PWR) while a ground adapter +# reports per-frame RSSI / SNR / EVM (). On a healthy link EVM +# improves monotonically as RSSI rises; at the front-end saturation knee it +# STOPS improving and reverses — more signal made it worse. This finds that +# knee and, as a side effect, calibrates the RSSI scale (does raw RSSI climb +# toward a ceiling as power rises?) and the EVM sign (which direction is good?). +# +# Two adapters, no SDR: TX = 8812AU, ground = a second devourer part. Keep them +# at a FIXED short bench distance (this is the near-field regime the classifier +# has to recognise). +# +# Output per index: idx=N rssi=med snr=med evm=med frames=K +# plus a fitted summary: the EVM-optimal index (the linear operating point) and +# whether an EVM turnover (saturation) was observed. +# +# Usage: sudo -v && tests/saturation_knee_sweep.sh [tx_pid] [ground_pid] +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${SATKNEE_OUT:-/tmp/devourer-sat-knee}" +CH="${CH:-36}" +TX_PID="${1:-0x8812}" TX_VID="${TX_VID:-0x0bda}" +GROUND_PID="${2:-0xc812}" GROUND_VID="${GROUND_VID:-0x0bda}" +# Full 6-bit index ladder (J1/J2 range). Jaguar3's ref is 7-bit but the demo's +# DEVOURER_TX_PWR clamps to the family; the ladder still spans low->high power. +IDXS="${IDXS:-4 10 16 22 28 34 40 46 52 58 63}" +mkdir -p "$OUT" + +cleanup() { + pkill -x WiFiDriverTxDem 2>/dev/null || true + pkill -x WiFiDriverDemo 2>/dev/null || true + true +} +trap cleanup EXIT INT TERM +plugged() { lsusb -d "$(printf '%04x:%04x' "$2" "$1")" >/dev/null 2>&1; } +plugged "$TX_PID" "$TX_VID" || { echo "SKIP: TX $TX_PID not plugged"; exit 0; } +plugged "$GROUND_PID" "$GROUND_VID" || { echo "SKIP: ground $GROUND_PID not plugged"; exit 0; } + +echo "== building ==" +cmake --build "$ROOT/build" -j --target WiFiDriverTxDemo WiFiDriverDemo >/dev/null || exit 1 + +echo "== ground RX ($GROUND_PID) up on ch$CH ==" +: >"$OUT/ground.log" +sudo -n env DEVOURER_PID="$GROUND_PID" DEVOURER_VID="$GROUND_VID" \ + DEVOURER_CHANNEL="$CH" DEVOURER_STREAM_OUT=1 \ + stdbuf -oL timeout 400 "$ROOT/build/WiFiDriverDemo" 2>"$OUT/ground.err" \ + | while IFS= read -r line; do printf '%s %s\n' "$(date +%s.%N)" "$line"; done \ + >>"$OUT/ground.log" & +GJ=$! +sleep 12 + +: >"$OUT/cells.txt" +for idx in $IDXS; do + t0="$(date +%s.%N)" + sudo -n env DEVOURER_PID="$TX_PID" DEVOURER_VID="$TX_VID" DEVOURER_CHANNEL="$CH" \ + DEVOURER_TX_RATE=MCS3 DEVOURER_TX_PWR="$idx" DEVOURER_TX_GAP_US=1500 \ + timeout 12 "$ROOT/build/WiFiDriverTxDemo" >/dev/null 2>&1 || true + t1="$(date +%s.%N)" + echo "$idx $t0 $t1" >>"$OUT/cells.txt" + sleep 2 +done +sudo -n pkill -x WiFiDriverDemo 2>/dev/null +wait "$GJ" 2>/dev/null + +python3 - "$OUT/ground.log" "$OUT/cells.txt" <<'PYEOF' +import re, statistics, sys +frames = [] # (t, rssi0, snr0, evm0) +rx = re.compile(r"^([0-9.]+) .*.*\brssi=(-?\d+),-?\d+ " + r"evm=(-?\d+),-?\d+ snr=(-?\d+),-?\d+") +for line in open(sys.argv[1], errors="replace"): + m = rx.match(line) + if m: + frames.append((float(m.group(1)), int(m.group(2)), + int(m.group(4)), int(m.group(3)))) # t, rssi, snr, evm +pts = [] +for line in open(sys.argv[2]): + idx, t0, t1 = line.split(); idx, t0, t1 = int(idx), float(t0), float(t1) + win = [(r, s, e) for (t, r, s, e) in frames if t0 + 6 <= t <= t1 - 1] + if len(win) < 15: + continue + rssi = int(statistics.median(x[0] for x in win)) + snr = int(statistics.median(x[1] for x in win)) + evm = int(statistics.median(x[2] for x in win)) + pts.append((idx, rssi, snr, evm, len(win))) + print(f" idx={idx} rssi={rssi} snr={snr} evm={evm} frames={len(win)}") +if len(pts) < 4: + print("RESULT insufficient points (ground caught too few frames)"); sys.exit(0) + +# EVM sign is unknown a priori: pick the "best" as the extreme that co-occurs +# with the HIGHEST SNR (SNR direction is unambiguous — higher = better). Then +# the EVM-optimal index is where EVM is best; a turnover = EVM getting worse at +# the top of the power ladder while RSSI still climbs. +best_snr_pt = max(pts, key=lambda p: p[2]) +lo_evm = min(p[3] for p in pts); hi_evm = max(p[3] for p in pts) +evm_lower_is_better = abs(best_snr_pt[3] - lo_evm) <= abs(best_snr_pt[3] - hi_evm) + +rssi_lo, rssi_hi = pts[0][1], pts[-1][1] +rssi_rises = rssi_hi > rssi_lo +# best EVM index +if evm_lower_is_better: + knee = min(pts, key=lambda p: p[3]) +else: + knee = max(pts, key=lambda p: p[3]) +# turnover: at the top-power cell, is EVM worse than at the knee AND SNR worse? +top = pts[-1] +worse_evm = (top[3] > knee[3]) if evm_lower_is_better else (top[3] < knee[3]) +turnover = worse_evm and top[0] > knee[0] and top[2] <= knee[2] + +print(f"RESULT rssi span {rssi_lo}->{rssi_hi} ({'rises' if rssi_rises else 'flat/falls'} " + f"with power); EVM {'lower' if evm_lower_is_better else 'higher'}=better; " + f"EVM-optimal at idx={knee[0]} (rssi={knee[1]} snr={knee[2]} evm={knee[3]}); " + f"saturation turnover={'YES' if turnover else 'no'} " + f"(top idx={top[0]} rssi={top[1]} snr={top[2]} evm={top[3]})") +PYEOF