From b8adf8b57f7d34b27ddc94e9bc3428786dd21b45 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:59:30 +0300 Subject: [PATCH] =?UTF-8?q?GetRxQuality()=20=E2=80=94=20runtime=20RX=20lin?= =?UTF-8?q?k-quality=20feed=20+=20passive=20noise=20floor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Fluke-style adaptive-link controller measures its environment from the driver: SNR/EVM, a noise-floor number (the self-jamming signal), and PA/LNA saturation. devourer had the per-frame RSSI/SNR/EVM (only on the Packet callback) and the LinkHealth classifier + windowing living demo-side — so a controller *linked against the library* (fluke_gs) couldn't get them without scraping stdout, and there was no noise-floor metric at all. - src/RxQuality.h — RxQuality snapshot + a thread-safe RxQualityAccumulator (the demo's RxAgg promoted, with a passive noise-floor term) + build_rx_quality fusing the frame aggregate, GetRxEnergy, and classify_link_health once. Noise floor is passive: nf_dbm = rssi_dbm - snr_db per OFDM/HT frame — the effective floor the receiver sees. TX self-jam drops SNR while RSSI holds, so it rises ("decrease txpower until NF returns to normal"). Works on all three families incl. Jaguar3 (which has no background DIG, so its IGI can't track the floor). - IRtlDevice::GetRxQuality() — the runtime feed; subsumes GetRxEnergy (calls it internally, consumes the FA/CCA delta). Each device owns an accumulator, feeds it per decoded frame in the RX loop, and returns build_rx_quality(...). - demo: DEVOURER_RXQUALITY=1 emits (dogfoods the API; existing / untouched). Validation: RxQualitySelftest (ctest + mingw list) covers the aggregate math, the NF formula against a synthesized saturation tuple, EVM/NF present-gates, and delta-reset. On-air (tests/rx_noise_floor_onair.sh): the feed is valid across a TX-power sweep and the noise floor is SELF-CONSISTENT on real frames (worst |nf-(rssi-snr)| = 1.0 dB), with SATURATED firing at high power. NB the bench can't produce a large controllable NF excursion — the wanted beacon is ~-50 dBm inches away and neither TX self-jam nor a co-located B210 AWGN at 78 dB moves the 8822EU's SNR (its AGC absorbs it); the same near-field/front-end limit the SDR-power + dis_cca work hit. The unit test covers the NF math directly. Full all-chips build + J1-only subset + ctest (10/10) green. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/cmake-multi-platform.yml | 1 + CMakeLists.txt | 10 + demo/main.cpp | 29 +++ src/IRtlDevice.h | 12 ++ src/RxQuality.h | 208 +++++++++++++++++++++ src/jaguar1/RtlJaguarDevice.cpp | 2 + src/jaguar1/RtlJaguarDevice.h | 11 ++ src/jaguar2/RtlJaguar2Device.cpp | 2 + src/jaguar2/RtlJaguar2Device.h | 8 + src/jaguar3/RtlJaguar3Device.cpp | 2 + src/jaguar3/RtlJaguar3Device.h | 10 + tests/rx_noise_floor_onair.sh | 127 +++++++++++++ tests/rx_quality_selftest.cpp | 113 +++++++++++ 13 files changed, 535 insertions(+) create mode 100644 src/RxQuality.h create mode 100755 tests/rx_noise_floor_onair.sh create mode 100644 tests/rx_quality_selftest.cpp 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; +}