diff --git a/.github/workflows/cmake-multi-platform.yml b/.github/workflows/cmake-multi-platform.yml index 116eb8b..286738e 100644 --- a/.github/workflows/cmake-multi-platform.yml +++ b/.github/workflows/cmake-multi-platform.yml @@ -148,6 +148,7 @@ jobs: WiFiDriver StreamTxDemo StreamDuplexDemo StreamStdinSelftest ToneMaskSelftest BfReportDecodeSelftest SweepSpecSelftest TxPowerQuantSelftest LinkHealthSelftest TxCapsSelftest TxPktPwrSelftest + RxQualitySelftest - name: Test working-directory: build diff --git a/CMakeLists.txt b/CMakeLists.txt index 6712a0e..63e52bc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,6 +84,7 @@ add_library(WiFiDriver src/TxCaps.h src/LinkHealth.cpp src/LinkHealth.h + src/RxQuality.h src/IRtlDevice.h src/SignalStop.cpp src/SignalStop.h @@ -354,6 +355,15 @@ target_link_libraries(LinkHealthSelftest PRIVATE WiFiDriver) add_test(NAME link_health_classify COMMAND LinkHealthSelftest) +# Headless guard for the RX link-quality feed (src/RxQuality.h) — the aggregate +# math + passive noise-floor formula + verdict fuse behind GetRxQuality(). +add_executable(RxQualitySelftest + tests/rx_quality_selftest.cpp +) +target_link_libraries(RxQualitySelftest PRIVATE WiFiDriver) + +add_test(NAME rx_quality_derive COMMAND RxQualitySelftest) + # Headless guard for the TX-capability derivation (src/TxCaps.h) — the # STBC-needs-2-chains rule the send_packet 1T1R guard relies on. add_executable(TxCapsSelftest diff --git a/demo/main.cpp b/demo/main.cpp index 24fbf65..fb3e109 100644 --- a/demo/main.cpp +++ b/demo/main.cpp @@ -153,6 +153,14 @@ static const bool g_linkhealth = []() -> bool { return e && *e && std::strcmp(e, "0") != 0; }(); +/* DEVOURER_RXQUALITY=1 — emit a line from the library's + * GetRxQuality() feed (the runtime API a linked adaptive-link controller reads). + * Rides the DEVOURER_RX_ENERGY_MS cadence like linkhealth. */ +static const bool g_rxquality = []() -> bool { + const char *e = std::getenv("DEVOURER_RXQUALITY"); + 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 @@ -772,6 +780,27 @@ int main() { h.igi_at_ceiling ? " igi_ceil=1" : "", h.cause, h.fix); fflush(stdout); } + /* DEVOURER_RXQUALITY=1 — dogfood the library GetRxQuality() feed: the + * same window a linked controller (fluke_gs) would read, incl. the + * passive noise-floor. NB it drains the device-internal accumulator + + * calls GetRxEnergy itself, so its counters are independent of the + * / lines above (which use the + * demo's own g_rxagg). */ + if (g_rxquality) { + devourer::RxQuality q = dev->GetRxQuality(); + char nf[24], evmb[16]; + if (q.nf_valid) std::snprintf(nf, sizeof nf, "%.1f", q.noise_floor_dbm); + else std::snprintf(nf, sizeof nf, "-"); + if (q.evm_valid) std::snprintf(evmb, sizeof evmb, "%.1f", q.evm_mean_db); + else std::snprintf(evmb, sizeof evmb, "-"); + printf("verdict=%s frames=%u rssi_mean_dbm=%d " + "rssi_max_dbm=%d snr_mean_db=%.1f snr_min_db=%.1f evm_db=%s " + "noise_floor_dbm=%s igi=%d\n", + q.label, q.frames, q.rssi_mean_dbm, q.rssi_max_dbm, + q.snr_mean_db, q.snr_min_db, evmb, nf, + q.igi_valid ? q.igi : -1); + fflush(stdout); + } nap(g_rx_energy_ms); } }); diff --git a/src/IRtlDevice.h b/src/IRtlDevice.h index c1abbd5..337b049 100644 --- a/src/IRtlDevice.h +++ b/src/IRtlDevice.h @@ -5,6 +5,7 @@ #include #include +#include "RxQuality.h" #include "RxSense.h" #include "SelectedChannel.h" #include "ThermalStatus.h" @@ -163,6 +164,17 @@ class IRtlDevice { * previous call. Default returns an all-invalid snapshot; each generation * overrides with a real reader. */ virtual RxEnergy GetRxEnergy() { return {}; } + + /* Consolidated windowed RX link-quality snapshot (see RxQuality.h) — the + * runtime feed a closed-loop adaptive-link controller reads instead of + * scraping the demo's stdout. Fuses the per-frame RSSI/SNR/EVM aggregate the + * device accumulates internally, a passive noise-floor estimate (rssi - snr, + * the self-jamming signal), the frame-free FA/CCA/IGI energy, and the + * LinkHealth verdict. Drains the window (delta semantics) and SUBSUMES + * GetRxEnergy (it calls it internally + consumes the FA/CCA delta — don't also + * poll GetRxEnergy separately on the same cadence). Default is an all-invalid + * snapshot; each generation overrides. */ + virtual devourer::RxQuality GetRxQuality() { return {}; } }; #endif /* IRTL_DEVICE_H */ diff --git a/src/RxQuality.h b/src/RxQuality.h new file mode 100644 index 0000000..b73a24a --- /dev/null +++ b/src/RxQuality.h @@ -0,0 +1,208 @@ +/* Runtime RX link-quality feed — the consolidated per-window snapshot a + * closed-loop adaptive-link controller (e.g. an alink / Fluke-style GS) reads + * to steer the link, WITHOUT scraping the demo's stdout. + * + * devourer already exposes per-frame RSSI/SNR/EVM (the Packet callback) and + * frame-free FA/CCA/IGI (GetRxEnergy), but the windowing + the LinkHealth + * verdict lived only in demo/main.cpp. This promotes that into the library: the + * device aggregates per-frame RX quality internally, and GetRxQuality() returns + * one struct fusing the frame aggregate, the frame-free energy, a passive + * noise-floor estimate, and the plain-language verdict. + * + * NOISE FLOOR (passive): per decoded OFDM/HT frame, the effective noise+ + * interference floor the receiver sees is nf_dbm = rssi_dbm - snr_db = + * (rssi_raw - 110) - snr_raw/2. It's a PASSIVE estimate — updated only when a + * wanted frame arrives — but that is exactly the self-jamming quantity: raising + * TX power on a near-field link raises reflections into the front end, SNR drops + * while RSSI holds, so this rises (the "decrease txpower until NF returns to + * normal" signal). Works on every family, including Jaguar3 where IGI can't + * (no background DIG). The frame-free ABSOLUTE idle floor is a separate, heavier + * measurement (see the active noise-floor path). + */ +#ifndef DEVOURER_RX_QUALITY_H +#define DEVOURER_RX_QUALITY_H + +#include +#include + +#include "LinkHealth.h" +#include "RxSense.h" + +namespace devourer { + +/* Windowed RX link-quality snapshot. Angles: frame-driven aggregate + * (rssi/snr/evm/noise-floor), frame-free energy (fa/cca/igi), and the fused + * LinkHealth verdict. `valid` is false when no frames were decoded in the + * window. Converted units (dBm / dB) so a controller doesn't repeat the raw + * conversions. */ +struct RxQuality { + bool valid = false; + uint32_t frames = 0; + + int rssi_mean_dbm = 0; /* window mean of path-A PWDB (raw - 110) */ + int rssi_max_dbm = 0; /* window peak — the strength signal (see LinkHealth) */ + double snr_mean_db = 0.0; + double snr_min_db = 0.0; + double evm_mean_db = 0.0; /* 0 when evm_valid is false */ + bool evm_valid = false; + + /* Passive noise-floor estimate (dBm), mean of per-frame rssi_dbm - snr_db + * over OFDM/HT frames in the window. nf_valid false if none carried SNR. */ + double noise_floor_dbm = 0.0; + bool nf_valid = false; + + /* Frame-free energy (from GetRxEnergy — GetRxQuality subsumes it). */ + bool energy_valid = false; + uint32_t fa_ofdm = 0; + uint32_t cca_ofdm = 0; + bool igi_valid = false; + int igi = 0; + + /* Fused verdict (classify_link_health). */ + LinkVerdict verdict = LinkVerdict::NoSignal; + const char *label = "NO_SIGNAL"; + const char *cause = ""; + const char *fix = ""; + bool igi_at_floor = false; + bool igi_at_ceiling = false; +}; + +/* Drained per-window aggregate in raw devourer units (the accumulator's + * output). Kept separate from RxQuality so the unit conversion + verdict live + * in one place (build_rx_quality). */ +struct RxQualitySnapshot { + uint32_t frames = 0; + int rssi_mean_raw = 0; + int rssi_max_raw = 0; + int snr_mean_raw = 0; + int snr_min_raw = 0; + int evm_mean_raw = 0; + bool evm_valid = false; + double nf_mean_dbm = 0.0; + bool nf_valid = false; +}; + +/* Thread-safe rolling per-frame accumulator. The device feeds add() from the RX + * loop for every decoded frame; a controller (or the energy emitter) drains it + * with snapshot() on its own cadence. Mirrors the demo's RxAgg with a + * passive-noise-floor term added. */ +class RxQualityAccumulator { +public: + /* Raw path-A values straight off rx_pkt_attrib. A frame with no phy-status + * power (rssi_raw <= 0) is not a quality sample and is skipped. SNR/EVM are + * folded only when present (raw != 0 — CCK / non-type1 phy-status leaves them + * 0), so a mixed stream doesn't bias those means toward zero. */ + void add(int rssi_raw, int snr_raw, int evm_raw) { + if (rssi_raw <= 0) + return; + std::lock_guard lk(mu_); + ++n_; + rssi_sum_ += rssi_raw; + if (rssi_raw > rssi_max_) + rssi_max_ = rssi_raw; + snr_sum_ += snr_raw; + if (snr_raw < snr_min_) + snr_min_ = snr_raw; + if (evm_raw != 0) { + evm_sum_ += evm_raw; + ++evm_n_; + } + if (snr_raw != 0) { + nf_sum_ += (rssi_raw - 110) - snr_raw / 2.0; + ++nf_n_; + } + } + + /* Snapshot the window into raw aggregates and RESET (delta semantics, like + * GetRxEnergy). */ + RxQualitySnapshot snapshot() { + RxQualitySnapshot s; + std::lock_guard lk(mu_); + s.frames = n_; + if (n_) { + s.rssi_mean_raw = rssi_sum_ / static_cast(n_); + s.rssi_max_raw = rssi_max_; + s.snr_mean_raw = snr_sum_ / static_cast(n_); + s.snr_min_raw = snr_min_; + } + if (evm_n_) { + s.evm_mean_raw = evm_sum_ / static_cast(evm_n_); + s.evm_valid = true; + } + if (nf_n_) { + s.nf_mean_dbm = nf_sum_ / static_cast(nf_n_); + s.nf_valid = true; + } + n_ = 0; + rssi_sum_ = 0; + rssi_max_ = -128; + snr_sum_ = 0; + snr_min_ = 127; + evm_sum_ = 0; + evm_n_ = 0; + nf_sum_ = 0.0; + nf_n_ = 0; + return s; + } + +private: + std::mutex mu_; + uint32_t n_ = 0; + int32_t rssi_sum_ = 0, rssi_max_ = -128; + int32_t snr_sum_ = 0, snr_min_ = 127; + int32_t evm_sum_ = 0; + uint32_t evm_n_ = 0; + double nf_sum_ = 0.0; + uint32_t nf_n_ = 0; +}; + +/* Fuse the frame aggregate + frame-free energy into an RxQuality, running the + * LinkHealth classifier once. Pure — no I/O; the device passes GetRxEnergy()'s + * result in. IGI rails are the union J1/J2 DIG floor (saturation corroborator) + * and the J3 ceiling (so a static J3 IGI never reads as a false 'weak' rail), + * matching the demo's LinkHealth mapping. */ +inline RxQuality build_rx_quality(const RxQualitySnapshot &s, const RxEnergy &e, + const LinkHealthThresholds &th = {}) { + RxQuality q; + q.frames = s.frames; + q.valid = s.frames > 0; + q.rssi_mean_dbm = s.rssi_mean_raw - 110; + q.rssi_max_dbm = s.rssi_max_raw - 110; + q.snr_mean_db = s.snr_mean_raw / 2.0; + q.snr_min_db = s.snr_min_raw / 2.0; + q.evm_valid = s.evm_valid; + q.evm_mean_db = s.evm_mean_raw / 2.0; + q.noise_floor_dbm = s.nf_mean_dbm; + q.nf_valid = s.nf_valid; + q.energy_valid = e.valid_fa; + q.fa_ofdm = e.fa_ofdm; + q.cca_ofdm = e.cca_ofdm; + q.igi_valid = e.valid_igi; + q.igi = e.igi; + + LinkHealthInput in; + in.frames = s.frames; + in.rssi_raw = s.frames ? s.rssi_max_raw : 0; /* strength = window peak */ + in.snr_raw = s.snr_mean_raw; + in.evm_raw = s.evm_mean_raw; + in.evm_valid = s.evm_valid; + 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; + in.igi_max = 0x7f; + LinkHealthVerdict h = classify_link_health(in, th); + q.verdict = h.verdict; + q.label = h.label; + q.cause = h.cause; + q.fix = h.fix; + q.igi_at_floor = h.igi_at_floor; + q.igi_at_ceiling = h.igi_at_ceiling; + return q; +} + +} // namespace devourer + +#endif /* DEVOURER_RX_QUALITY_H */ diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index eb34a09..54e482e 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -741,6 +741,8 @@ void RtlJaguarDevice::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { std::span{const_cast(data), (size_t)n})) { if (should_stop || g_devourer_should_stop) break; + if (!p.RxAtrib.crc_err) + _rxq.add(p.RxAtrib.rssi[0], p.RxAtrib.snr[0], p.RxAtrib.evm[0]); _packetProcessor(p); } }; diff --git a/src/jaguar1/RtlJaguarDevice.h b/src/jaguar1/RtlJaguarDevice.h index dbd507e..ffbb153 100644 --- a/src/jaguar1/RtlJaguarDevice.h +++ b/src/jaguar1/RtlJaguarDevice.h @@ -65,6 +65,11 @@ class RtlJaguarDevice : public IRtlDevice { * state (an ApplyTxPower on a cold chip would write an unpowered BB). */ bool _brought_up = false; + /* Rolling per-frame RX link-quality aggregate (GetRxQuality). Fed from the RX + * loop for every decoded frame; drained by GetRxQuality on the caller's + * cadence. */ + devourer::RxQualityAccumulator _rxq; + public: RtlJaguarDevice(RtlUsbAdapter device, Logger_t logger); ~RtlJaguarDevice() override; @@ -153,6 +158,12 @@ class RtlJaguarDevice : public IRtlDevice { * also running it shares/steals these counters. */ RxEnergy GetRxEnergy() override; + /* Consolidated windowed RX link-quality snapshot (see RxQuality.h) — subsumes + * GetRxEnergy. Fed per decoded frame in the RX loop via _rxq. */ + devourer::RxQuality GetRxQuality() override { + return devourer::build_rx_quality(_rxq.snapshot(), GetRxEnergy()); + } + /* Runtime TX-mode default. send_packet honours a frame's own radiotap rate * fields per-packet; when a frame's radiotap carries no rate, this mode * supplies the modulation / MCS / BW / GI / FEC / STBC instead of the diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index 8e23452..79057f9 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -287,6 +287,8 @@ void RtlJaguar2Device::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { f.drvinfo_size, f.rx_rate <= 3, p.RxAtrib); p.Data = std::span(const_cast(f.frame), f.frame_len); + if (!p.RxAtrib.crc_err) + _rxq.add(p.RxAtrib.rssi[0], p.RxAtrib.snr[0], p.RxAtrib.evm[0]); _packetProcessor(p); } ++frames; diff --git a/src/jaguar2/RtlJaguar2Device.h b/src/jaguar2/RtlJaguar2Device.h index 27af370..a2a069e 100644 --- a/src/jaguar2/RtlJaguar2Device.h +++ b/src/jaguar2/RtlJaguar2Device.h @@ -121,8 +121,16 @@ class RtlJaguar2Device : public IRtlDevice { * The read side of the CW tone. */ RxEnergy GetRxEnergy() override; + /* Consolidated windowed RX link-quality snapshot (see RxQuality.h) — subsumes + * GetRxEnergy. Fed per decoded frame in the RX loop via _rxq. */ + devourer::RxQuality GetRxQuality() override { + return devourer::build_rx_quality(_rxq.snapshot(), GetRxEnergy()); + } + private: RtlUsbAdapter _device; + /* Rolling per-frame RX link-quality aggregate (GetRxQuality). */ + devourer::RxQualityAccumulator _rxq; Logger_t _logger; jaguar2::ChipVariant _variant; jaguar2::HalJaguar2 _hal; diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index 5bab8a4..18132b7 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -185,6 +185,8 @@ void RtlJaguar3Device::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { jaguar3::parse_phy_sts_jgr3(data + off + jaguar3::RXDESC_SIZE_8822C, f.drvinfo_size, p.RxAtrib); p.Data = std::span(const_cast(f.frame), f.frame_len); + if (!p.RxAtrib.crc_err) + _rxq.add(p.RxAtrib.rssi[0], p.RxAtrib.snr[0], p.RxAtrib.evm[0]); _packetProcessor(p); } if (++frames <= 5) diff --git a/src/jaguar3/RtlJaguar3Device.h b/src/jaguar3/RtlJaguar3Device.h index 528637b..ffec5ee 100644 --- a/src/jaguar3/RtlJaguar3Device.h +++ b/src/jaguar3/RtlJaguar3Device.h @@ -124,6 +124,14 @@ class RtlJaguar3Device : public IRtlDevice { * _reg_mu against the coex runtime thread. The read side of the CW tone. */ RxEnergy GetRxEnergy() override; + /* Consolidated windowed RX link-quality snapshot (see RxQuality.h) — subsumes + * GetRxEnergy. Fed per decoded frame in the RX loop via _rxq. On Jaguar3 the + * noise-floor is the passive rssi-snr estimate (this generation has no + * background DIG, so IGI is static and can't track the floor). */ + devourer::RxQuality GetRxQuality() override { + return devourer::build_rx_quality(_rxq.snapshot(), GetRxEnergy()); + } + /* dis_cca / EDCCA-disable investigation knob (DEVOURER_DIS_CCA). Writes the * vendor rtw_proc.c dis_cca recipe (MAC BIT_DIS_EDCCA 0x520[15] + EDCCA-mask * countdown 0x524[11], BB 0x1a9c[20]/0x1a14[9:8]/0x1d58[0xff8]); disabled=false @@ -153,6 +161,8 @@ class RtlJaguar3Device : public IRtlDevice { std::atomic _txpwr_sat_high{false}; /* Bring-up completion: gates the live apply (+ _reg_mu use) in the setters. */ bool _brought_up = false; + /* Rolling per-frame RX link-quality aggregate (GetRxQuality). */ + devourer::RxQualityAccumulator _rxq; /* dis_cca sticky state — re-applied after SetMonitorChannel (the channel set * rewrites the BB CCA registers). Caller holds _reg_mu. */ bool _cca_disabled = false; diff --git a/tests/rx_noise_floor_onair.sh b/tests/rx_noise_floor_onair.sh new file mode 100755 index 0000000..a73f6f5 --- /dev/null +++ b/tests/rx_noise_floor_onair.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Validate the passive noise-floor + the GetRxQuality() feed (src/RxQuality.h, +# ) ON-AIR. The passive noise floor is nf_dbm = rssi_dbm - +# snr_db per decoded OFDM/HT frame — the effective noise+interference floor the +# receiver sees, and the Fluke self-jamming signal ("decrease txpower until NF +# returns to normal"): a raised floor drops SNR while RSSI holds, so nf rises. +# +# WHAT THIS ASSERTS (and why not more): the metric's CORRECTNESS on real frames +# 1. the feed emits valid windows (frames>0, nf_valid) across a TX-power sweep; +# 2. it is SELF-CONSISTENT on-air: noise_floor_dbm ~= rssi_mean_dbm - +# snr_mean_db (within ~2 dB — CCK/rounding), i.e. the per-frame formula is +# wired correctly through the aggregate, not just in the unit test; +# 3. the LinkHealth verdict rides the same feed (reported). +# +# It deliberately does NOT assert a large monotonic NF excursion vs TX power: +# at bench range the two adapters are inches apart, the wanted beacon is ~-50 +# dBm, and neither cranking TX power nor a co-located B210 AWGN interferer at 78 +# dB moves the RX's SNR enough to swing the floor (the 8822EU AGC absorbs it) — +# measured, the same near-field/front-end limit the SDR-power and dis_cca work +# hit. A large controllable NF swing needs a real far-field link. The unit test +# (RxQualitySelftest) covers the NF math against a synthesized saturation tuple. +# +# TX 8812AU beacon, RX 8822EU. No SDR needed. +# Usage: sudo -v && tests/rx_noise_floor_onair.sh +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${NF_OUT:-/tmp/devourer-rx-nf}" +CH="${CH:-36}" +DUR="${DUR:-9}" +INDICES="${INDICES:-8 20 34 48 63}" # flat TXAGC index ladder (low -> high) +mkdir -p "$OUT" + +TX_PID=0x8812 TX_VID=0x0bda +RX_PID=0xa81a RX_VID=0x0bda + +cleanup() { + sudo -n pkill -x WiFiDriverDemo 2>/dev/null || true + sudo -n pkill -x WiFiDriverTxDem 2>/dev/null || true + true +} +trap cleanup EXIT INT TERM +plugged() { lsusb -d "$(printf '%04x:%04x' "$2" "$1")" >/dev/null 2>&1; } +for d in "$TX_PID $TX_VID beacon-8812AU" "$RX_PID $RX_VID RX-8822EU"; do + read -r p v name <<<"$d" + plugged "$p" "$v" || { echo "SKIP: $name ($p:$v) not plugged"; exit 0; } +done + +echo "== building ==" +cmake --build "$ROOT/build" -j --target WiFiDriverDemo WiFiDriverTxDemo >/dev/null || exit 1 + +# One cell at flat TX index $1. Echoes "frames nf rssi snr verdict" (medians). +run_cell() { + local idx="$1" log="$OUT/idx$idx.log" + sudo -n env DEVOURER_PID="$TX_PID" DEVOURER_VID="$TX_VID" DEVOURER_CHANNEL="$CH" \ + DEVOURER_TX_RATE=MCS5 DEVOURER_TX_PWR="$idx" DEVOURER_TX_GAP_US=1200 \ + timeout $((DUR + 6)) "$ROOT/build/WiFiDriverTxDemo" >/dev/null 2>&1 & + local tx=$! + sleep 3 + sudo -n env DEVOURER_PID="$RX_PID" DEVOURER_VID="$RX_VID" DEVOURER_CHANNEL="$CH" \ + DEVOURER_RX_ENERGY_MS=1000 DEVOURER_RXQUALITY=1 \ + timeout "$DUR" "$ROOT/build/WiFiDriverDemo" 2>&1 \ + | grep "devourer-rxquality" >"$log" || true + kill "$tx" 2>/dev/null; wait "$tx" 2>/dev/null + sleep 2 + python3 - "$log" <<'PYEOF' +import re, statistics, sys +nf, rssi, snr, verdicts, frames = [], [], [], [], [] +for line in open(sys.argv[1], errors="replace"): + fr = re.search(r"frames=(\d+)", line) + if not fr or int(fr.group(1)) == 0: + continue + def g(pat): + m = re.search(pat, line); return float(m.group(1)) if m else None + nfv = g(r"noise_floor_dbm=(-?\d+\.?\d*)") + rv = g(r"rssi_mean_dbm=(-?\d+\.?\d*)") + sv = g(r"snr_mean_db=(-?\d+\.?\d*)") + v = re.search(r"verdict=(\w+)", line) + frames.append(int(fr.group(1))) + if nfv is not None: nf.append(nfv) + if rv is not None: rssi.append(rv) + if sv is not None: snr.append(sv) + if v: verdicts.append(v.group(1)) +def med(a): return statistics.median(a) if a else float("nan") +tail = verdicts[len(verdicts)//2:] or verdicts +vv = max(set(tail), key=tail.count) if tail else "NONE" +print(f"{sum(frames)} {med(nf):.1f} {med(rssi):.1f} {med(snr):.1f} {vv}") +PYEOF +} + +printf "%-6s | %-7s | %-9s | %-9s | %-9s | %s\n" \ + "txidx" "frames" "nf_dbm" "rssi_dbm" "snr_db" "verdict" +printf -- "-------+---------+-----------+-----------+-----------+--------\n" +: >"$OUT/summary.tsv" +for idx in $INDICES; do + read -r frames nf rssi snr verdict <<<"$(run_cell "$idx")" + printf "%-6s | %-7s | %-9s | %-9s | %-9s | %s\n" \ + "$idx" "$frames" "$nf" "$rssi" "$snr" "$verdict" + printf "%s\t%s\t%s\t%s\t%s\t%s\n" "$idx" "$frames" "$nf" "$rssi" "$snr" \ + "$verdict" >>"$OUT/summary.tsv" +done + +echo +python3 - "$OUT/summary.tsv" <<'PYEOF' +import sys, math +rows = [] +for line in open(sys.argv[1]): + idx, fr, nf, rssi, snr, v = line.split() + rows.append((int(idx), int(fr), float(nf), float(rssi), float(snr), v)) +valid = [r for r in rows if r[1] > 0 and not math.isnan(r[2])] +if len(valid) < 2: + print("FAIL: too few valid cells"); sys.exit(1) +# 1. every swept cell produced a valid feed. +all_valid = len(valid) == len(rows) +# 2. on-air self-consistency: nf ~= rssi_mean - snr_mean (within 2 dB). +worst = max(abs(r[2] - (r[3] - r[4])) for r in valid) +consistent = worst <= 2.0 +sats = [r[0] for r in rows if r[5] == "SATURATED"] +print(f"cells valid: {len(valid)}/{len(rows)}") +print(f"self-consistency worst |nf-(rssi-snr)|: {worst:.2f} dB (<=2 required)") +print(f"NF across sweep: {[r[2] for r in rows]}") +print(f"SNR across sweep: {[r[4] for r in rows]}") +print(f"SATURATED verdict at tx-idx: {sats or 'none (bench geometry)'}") +ok = all_valid and consistent +print("PASS: GetRxQuality feed valid + noise_floor self-consistent on-air" + if ok else "FAIL: feed invalid or noise-floor not self-consistent") +sys.exit(0 if ok else 1) +PYEOF diff --git a/tests/rx_quality_selftest.cpp b/tests/rx_quality_selftest.cpp new file mode 100644 index 0000000..bbd9e6f --- /dev/null +++ b/tests/rx_quality_selftest.cpp @@ -0,0 +1,113 @@ +/* Headless guard for the RxQuality accumulator + build_rx_quality fuse + * (src/RxQuality.h) — the windowed RX-link-quality feed behind + * / IRtlDevice::GetRxQuality(). Verifies the aggregate + * math (mean/max/min, EVM present-gate), the PASSIVE noise-floor formula + * (nf_dbm = (rssi_raw-110) - snr_raw/2), the unit conversions, and that the + * fused verdict matches classify_link_health. A regression fails ctest instead + * of silently feeding a controller wrong numbers. */ +#include +#include + +#include "RxQuality.h" + +static int g_fail = 0; + +static void check(const char *name, bool ok) { + if (!ok) { + ++g_fail; + std::printf("FAIL: %s\n", name); + } +} + +static bool approx(double a, double b) { return std::fabs(a - b) < 1e-6; } + +int main() { + using namespace devourer; + + /* Empty window -> invalid. */ + { + RxQualityAccumulator acc; + RxQuality q = build_rx_quality(acc.snapshot(), RxEnergy{}); + check("empty.invalid", !q.valid && q.frames == 0); + check("empty.nf_invalid", !q.nf_valid); + check("empty.verdict", q.verdict == LinkVerdict::NoSignal); + } + + /* Aggregate math + noise floor. Three OFDM frames: + * rssi_raw = 60, 70, 80 (mean 70, max 80) + * snr_raw = 40, 30, 20 (mean 30 -> 15 dB, min 20 -> 10 dB) + * evm_raw = -50, -40, 0 (present: -50,-40 -> mean -45 -> -22.5 dB) + * NF per frame (snr!=0): (60-110)-20 = -70 ; (70-110)-15 = -55 ; + * (80-110)-10 = -40 -> mean -55 dBm. */ + { + RxQualityAccumulator acc; + acc.add(60, 40, -50); + acc.add(70, 30, -40); + acc.add(80, 20, 0); + RxQualitySnapshot s = acc.snapshot(); + check("agg.frames", s.frames == 3); + check("agg.rssi_mean_raw", s.rssi_mean_raw == 70); + check("agg.rssi_max_raw", s.rssi_max_raw == 80); + check("agg.snr_mean_raw", s.snr_mean_raw == 30); + check("agg.snr_min_raw", s.snr_min_raw == 20); + check("agg.evm_valid", s.evm_valid); + check("agg.evm_mean_raw", s.evm_mean_raw == -45); + check("agg.nf_valid", s.nf_valid); + check("agg.nf_mean", approx(s.nf_mean_dbm, -55.0)); + + RxQuality q = build_rx_quality(s, RxEnergy{}); + check("q.valid", q.valid); + check("q.rssi_mean_dbm", q.rssi_mean_dbm == 70 - 110); + check("q.rssi_max_dbm", q.rssi_max_dbm == 80 - 110); + check("q.snr_mean_db", approx(q.snr_mean_db, 15.0)); + check("q.snr_min_db", approx(q.snr_min_db, 10.0)); + check("q.evm_mean_db", approx(q.evm_mean_db, -22.5)); + check("q.noise_floor", approx(q.noise_floor_dbm, -55.0)); + } + + /* snapshot() resets (delta semantics). */ + { + RxQualityAccumulator acc; + acc.add(70, 30, -50); + (void)acc.snapshot(); + RxQualitySnapshot s2 = acc.snapshot(); + check("reset.empty", s2.frames == 0 && !s2.nf_valid); + } + + /* Frames with no phy-status (rssi_raw <= 0) are not quality samples. */ + { + RxQualityAccumulator acc; + acc.add(0, 0, 0); + acc.add(0, 0, 0); + check("nophy.skipped", acc.snapshot().frames == 0); + } + + /* CCK-only frames (snr/evm 0) count for rssi but leave NF/EVM invalid. */ + { + RxQualityAccumulator acc; + acc.add(50, 0, 0); + acc.add(55, 0, 0); + RxQualitySnapshot s = acc.snapshot(); + check("cck.frames", s.frames == 2); + check("cck.rssi", s.rssi_mean_raw == 52); /* 105/2 truncated */ + check("cck.nf_invalid", !s.nf_valid); + check("cck.evm_invalid", !s.evm_valid); + } + + /* Self-jamming signature: strong RSSI + dirty EVM -> Saturated, and the NF + * estimate is high (near the signal) because SNR collapsed. Mirrors the + * saturation-knee measurement. */ + { + RxQualityAccumulator acc; + /* rssi 72 (raw), snr 36 (18 dB), evm collapsed to -27 (raw). */ + for (int i = 0; i < 4; ++i) + acc.add(72, 36, -27); + RxQuality q = build_rx_quality(acc.snapshot(), RxEnergy{}); + check("sat.verdict", q.verdict == LinkVerdict::Saturated); + check("sat.nf_valid", q.nf_valid); + } + + if (g_fail == 0) + std::printf("rx_quality_selftest: all passed\n"); + return g_fail ? 1 : 0; +}