From 4b6fd63ebcba66e24cecb80f40c896f112496bd2 Mon Sep 17 00:00:00 2001 From: snokvist Date: Sun, 20 Sep 2026 13:57:27 +0200 Subject: [PATCH] sensing: pin the busy window's two asymmetries, and three stale doc facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #437 landed the armed busy window. Two of its rules are asymmetric, and the suite passes with either one inverted — so they are currently held by comments rather than by tests. Verified by mutation against this tree, before the tests below. Each mutation is caught by exactly one new assertion: - ClmWindow::read consumes a SPOILED window but keeps a NOT-ELAPSED one. Deleting the `armed_ = false` from the spoil branch — the tidy-looking "consume once, at the top" simplification — makes every existing check in busy_window_selftest.cpp pass. Consuming is what hands the caller back to the sampled path: IRtlRadio::GetChannelBusy takes the armed branch on armed(), and `spoil_` is sticky (only arm() clears it), so a spoiled window left armed strands every later call there returning invalid-Retuned, with the sampled fallback unreachable for every unrelated consumer until something re-arms or resets it. Not a wrong number — a sensor that stops answering. - busy_from_ch_time_window reports a lost window only when one was ARMED. Dropping the `w.armed &&` makes every existing check in channel_busy_selftest.cpp pass, while telling every sampled caller that its window was lost when it never armed one. "No sensor here" and "your window died" are the two facts the spoil field exists to separate. A third, smaller gap closes with them: the existing retune block reads `w.last_spoil()`, so a branch reporting every spoil as Interrupted passes the whole suite. What a consumer acts on is `ChannelBusy::spoil`, so that is what the new block asserts. Three documentation facts that #437 overtook: - AdapterCaps.h names Jaguar1 and the MT7612U as the false-as-unmeasured cases for busy_airtime_measured. #437 measured both and flipped them; all four backends that set busy_airtime_ok now set measured too. It also cited tests/ccx_clm_probe.sh, which separates a CARRIER from a quiet channel on the sampled read — the armed window's harness is tests/busy_window_probe.sh. Worst place in the tree to be stale, since it is where the flags are defined. - The portable-surface table still labels the MT7612U busy-airtime leg "unvalidated", which the same page contradicts two sections later. No production code changes. Verified: 69/69 ctest, ASan/UBSan clean on both selftests. As a no-change control — this commit touches no production code, so the run re-verifies #437's — tests/busy_window_probe.sh PASS on an 8812AU sensor against an MT7612U flooder on ch165, steady (armed 71%, spread 0 against the sampled path's 8) and bursty 50/450 (armed mean 9% with the true duty ~9%; the sampled read missed the load 5/6 and spread 70 against the armed window's 19). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SbJ75HT4fqof7e5e8NKL2j --- docs/rx-spectrum-sensing.md | 2 +- src/AdapterCaps.h | 12 ++++++++---- tests/busy_window_selftest.cpp | 34 +++++++++++++++++++++++++++++++++ tests/channel_busy_selftest.cpp | 15 +++++++++++++++ 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/docs/rx-spectrum-sensing.md b/docs/rx-spectrum-sensing.md index 4bc4bed9..d019f887 100644 --- a/docs/rx-spectrum-sensing.md +++ b/docs/rx-spectrum-sensing.md @@ -127,7 +127,7 @@ hardware it runs on: |---|---|---| | returns | `ChannelBusy` — busy airtime + energy-above-floor | `RxEnergy` — the phydm counter set | | available on | any backend with a hardware busy-airtime counter | Realtek only | -| today | Jaguar1/2/3 (CCX CLM), MT7612U (MAC channel timers, **unvalidated**) | Jaguar1/2/3, Kestrel (floor only) | +| today | Jaguar1/2/3 (CCX CLM), MT7612U (MAC channel timers) | Jaguar1/2/3, Kestrel (floor only) | | not available | Kestrel, RTL8733B — both report *no reading*, never zero | RTL8733B, MT7612U | Advertised statically by `AdapterCaps::busy_airtime_ok` / diff --git a/src/AdapterCaps.h b/src/AdapterCaps.h index a2c6adb8..772dd20d 100644 --- a/src/AdapterCaps.h +++ b/src/AdapterCaps.h @@ -280,10 +280,14 @@ struct AdapterCaps { * channel timers. FALSE on Kestrel (its NHM rides the halbb glue, not * NhmReader, so it has no CLM) and on the RTL8733B. * - * busy_airtime_measured: that reading has been separated arm-vs-quiet ON AIR - * for this family (tests/ccx_clm_probe.sh), not merely implemented. False on - * Jaguar1 (shares the validated Jaguar2 11AC map, unmeasured) and on the - * MT7612U (no adapter available). False-as-unmeasured, per the house rule. + * busy_airtime_measured: that reading has been separated from a quiet + * channel ON AIR for this family, not merely implemented. The harness is + * tests/busy_window_probe.sh, which pits an armed window against the quiet + * floor under a known load, and against each way a window can be spoiled. + * True today on all four backends that set busy_airtime_ok. The flag stays + * because the two facts are independent: a port can land the engine before + * anyone has run it on air, and false-as-unmeasured is the house rule for + * that state. * * rx_energy_ok: IRtlRadio::GetRxEnergy returns real phydm FA/CCA/IGI * counters. Always false on a non-Realtek radio; false on the RTL8733B and diff --git a/tests/busy_window_selftest.cpp b/tests/busy_window_selftest.cpp index 25c3d2f4..59eb0b18 100644 --- a/tests/busy_window_selftest.cpp +++ b/tests/busy_window_selftest.cpp @@ -244,6 +244,40 @@ int main() { check("early: consumed by the completed read", w.armed() ? 1 : 0, 0); } + /* --- the other half of that rule: a SPOILED window IS consumed. + * + * "Not elapsed" keeps the window because nothing disturbed it — the caller + * was merely early. Interrupted and Retuned are the opposite: the hardware + * window was re-armed underneath it, or it spans two channels, so there is + * nothing left to wait for. + * + * Consuming it is what hands the caller BACK to the sampled path. + * IRtlRadio::GetChannelBusy takes the armed branch on armed(), so a spoiled + * window left armed strands every later call there: `spoil_` is sticky + * (only arm() clears it), so each one re-enters the spoil branch and + * returns invalid-Retuned, and the sampled fallback is unreachable for + * every unrelated consumer until something re-arms or resets the window. + * Not a wrong number — a sensor that stops answering. + * + * Without this block the asymmetry is invisible to the suite: a build that + * keeps the window armed on EVERY verdict passes every other test in this + * file. It is exactly the difference a later editor removes while tidying, + * so it is asserted rather than only described in the header. --- */ + { + MockBb bb; + ClmWindow w; + w.arm(regs, 240000, 0, bb.wr()); + w.note_retune(); + const ChannelBusy spoiled = w.read(regs, 0, bb.rd()); + check("spoiled: refused", spoiled.valid, 0); + /* On the READING, not just in last_spoil(): the block above checks the + * accessor, so a branch that reported every spoil as Interrupted would + * pass it. What a consumer acts on is the field. */ + check("spoiled: with its reason", static_cast(spoiled.spoil), + static_cast(BusySpoil::Retuned)); + check("spoiled: window consumed", w.armed() ? 1 : 0, 0); + } + /* --- reading without arming is not a quiet channel --- */ { MockBb bb; diff --git a/tests/channel_busy_selftest.cpp b/tests/channel_busy_selftest.cpp index c412ac95..bf42cfee 100644 --- a/tests/channel_busy_selftest.cpp +++ b/tests/channel_busy_selftest.cpp @@ -219,6 +219,21 @@ int main() { static_cast(b.spoil), static_cast(BusySpoil::Interrupted)); } + { /* The negative control for the block above. The SAME dead counters + * with no window armed are the sampled path finding a backend that is + * not counting — "no sensor here", which is what a bare invalid + * reading means, and it must stay bare. A build that reported a spoil + * reason here would tell every sampled caller that its window was + * lost, when it never armed one. + * + * This is what pins the `w.armed &&` half of the guard: without it the + * reason leaks onto the sampled path and no other test notices. */ + ChTimeWindow w; /* unarmed */ + const ChannelBusy b = busy_from_ch_time_window(w, 0, 0, 250000, false, 0); + check("mt unarmed over dead counters: no reading", b.valid, 0); + check("mt unarmed over dead counters: and no window to have lost", + static_cast(b.spoil), static_cast(BusySpoil::None)); + } } if (g_fail) {