From 2b71bfb83ec08451edd34d0abb590fa8e1d548c1 Mon Sep 17 00:00:00 2001 From: snokvist Date: Sun, 20 Sep 2026 08:53:56 +0200 Subject: [PATCH 1/4] sensing: a busy-airtime window the caller defines GetChannelBusy reads whatever window the shared NHM read happened to arm: ~2 ms, once. That is a sample, not a measurement of the caller's dwell, and on bursty traffic it is bimodal rather than merely noisy. Measured on an RTL8822BU against a 50 ms-on/450 ms-off interferer, one read per 300 ms read ZERO in 55 of 71 windows and ~63% in the rest, while 300-400 frames were decoded in nearly every one of them. A ranker that reads 0% as "emptiest" does not lose information there, it gets a wrong answer. CLM's period is its own field and reaches 65535 ticks of 4 us, so the hardware can integrate a whole dwell. ArmChannelBusy(window_us) on IRadio does that: arm where the counters are reset, read at the end. MediaTek's timers already integrate between reads, so there arming is resetting them and the mark. Both families then report busy airtime over the CALLER's window - not an identical measurement (one is hardware-timed and counts receive-side deferral, the other is the host-measured gap and counts own TX), which is what source/window_us/own_tx_in_window are for. tests/busy_window_probe.sh, 8812AU sensor, 6 samples on a ~9%-duty interferer: armed 240 ms window mean 10% spread 19 points; sampled 2 ms mean 24% spread 73 points. The sampled column is not just wider - it caught a burst twice in six reads and called a ~9% channel 24%. Under a steady load both agree (71%, spread 0 vs 6), which is why the bursty arm is the one that decides anything. A window only describes the dwell if nothing else touched it, and all three spoilers are measured rather than assumed: - An NHM read mid-window. The damage is register-map-dependent: the JGR3 map DESTROYS the window (326 of 62500 ticks where a clean read gave ~38000), the 11AC map survives it but reads 3-4 points HIGH. Enforced on both, because GetRxQuality() takes that NHM read - a consumer polling link quality inside its own survey dwell springs the trap without ever touching the busy API. The probe has an arm for exactly that call. - A retune mid-window: the counter runs across it and reports the blend of two channels (44-47% on a Jaguar3 where the channel was 61%). A Jaguar3 bandwidth change counts too, and so does a MediaTek retune. - A read before the window elapsed: the result register latches the PREVIOUS window and reads are non-destructive, so it is a stale number wearing a fresh timestamp. Each yields an INVALID reading carrying its reason (ChannelBusy::spoil), so a spoiled window is never a plausible number. The arm, the read and the family's own NHM read are serialised on one CCX mutex, taken under the family register lock where there is one (with_ccx). Without that, a telemetry thread's GetRxEnergy(true) could note "nothing armed", then re-arm the engine AFTER a concurrent arm committed - a destroyed window reading back valid, which is the failure this whole change exists to prevent. Bring-up forgets an armed window, and a no-op retune no longer spoils one. Own transmission is carried, not corrected: CLM counts receive-side deferral only and a radio is deaf while its own PA is up, so a transmitting sensor reads LOW (70.9% -> 24-26% on a Jaguar1, 60.9% -> 18.4-18.6% on a Jaguar3), while the MediaTek timers count own TX and read HIGH. The two families are biased in opposite directions, so own_tx_in_window/own_tx_frames are facts on the reading rather than a correction inside it. Also measured, and reflected in caps: Jaguar1 CLM (70.6-70.9% under a load a MediaTek independently measured, 0.1-1.0% quiet) and MT7612U channel timers (64.0-64.3% / 0.0% / 8.1-9.5% bursty). busy_airtime_measured is now true on both. Two MediaTek fixes came out of measuring it: with RX down its idle timer still advances, so busy+idle was non-zero and the ratio came out a plausible 0% - the fabricated zero this contract exists to prevent, now refused - and its arm stamps the interval mark instead of zeroing it, which had made the first read after an arm report window_us=0. Its arm also has its own narrow entry point rather than link_stats_start(), which would have reset the 1 Hz telemetry's MIB counters once per dwell. Validation: tests/busy_window_selftest.cpp with mocked masked-write semantics - the clamp, the CLM-only arm sequence asserted as a SEQUENCE (no NHM trigger edge, starting from the state a previous read_nhm leaves), the period-bounded ratio, every refusal including the stale-latch re-arm, spoil precedence, the JGR3 map, and own-TX accounting including a counter that went backwards. Two mutations confirm it bites: arming NHM alongside CLM fails the sequence check, ignoring the ready bit fails the stale and early cases. 67/67 ctest, ASan+UBSan clean. tests/busy_window_probe.sh drives ten on-air arms through the public contract only and gates on values, not just validity: the floor must be quiet, the load must separate from it, a spoiled window must refuse with its reason, a re-armed window must not return the latched previous result, and under a bursty load the armed spread must beat the sampled one. One arm is the locking itself - a second thread hammering GetRxQuality() for the whole window, where every reading must be refused rather than come back as a short window wearing a valid flag. PASS on an 8812AU sensor with an MT7612U flooder, steady and bursty; a MediaTek sensor runs the quiet arms and SKIPS the loaded ones, because its bring-up does not complete on an already-saturated channel. Not here: Kestrel and the RTL8733B return 0 from the arm and keep the sampled path; scoring laws are untouched; the saturation wrap near 100% is unmeasured (the bench flood tops out ~71%), which is why the period is capped at 240 ms rather than the hardware's 262. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SbJ75HT4fqof7e5e8NKL2j --- CLAUDE.md | 13 +- CMakeLists.txt | 23 ++ docs/rx-spectrum-sensing.md | 117 +++++++- examples/common/DeviceSession.h | 7 + src/BusyWindow.h | 175 ++++++++++++ src/IRadio.h | 31 ++ src/IRtlRadio.h | 85 ++++++ src/NhmReader.h | 47 ++++ src/RxSense.h | 46 ++- src/jaguar1/CLAUDE.md | 11 +- src/jaguar1/RtlJaguarDevice.cpp | 56 +++- src/jaguar1/RtlJaguarDevice.h | 16 ++ src/jaguar2/RtlJaguar2Device.cpp | 48 +++- src/jaguar2/RtlJaguar2Device.h | 15 + src/jaguar3/CLAUDE.md | 9 + src/jaguar3/RtlJaguar3Device.cpp | 49 +++- src/jaguar3/RtlJaguar3Device.h | 15 + src/mt7612u/CLAUDE.md | 30 +- src/mt7612u/Mt7612uRadio.cpp | 82 +++++- src/mt7612u/Mt7612uRadio.h | 6 + src/mt7612u/include/mt7612u/mt7612u.h | 8 + src/mt7612u/init.cpp | 40 ++- tests/busy_window_probe.cpp | 341 ++++++++++++++++++++++ tests/busy_window_probe.sh | 287 +++++++++++++++++++ tests/busy_window_selftest.cpp | 389 ++++++++++++++++++++++++++ 25 files changed, 1910 insertions(+), 36 deletions(-) create mode 100644 src/BusyWindow.h create mode 100644 tests/busy_window_probe.cpp create mode 100755 tests/busy_window_probe.sh create mode 100644 tests/busy_window_selftest.cpp diff --git a/CLAUDE.md b/CLAUDE.md index 2bd0db4c..ec422a40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -461,7 +461,18 @@ measured, so this buys an airtime unit and a non-railing ratio rather than a new detection; `nhm_env` is referenced to the live IGI, so it is only dependable where DIG is not free to walk the gain out from under it (the per-generation windows are in each `src//CLAUDE.md`); and the ~2 ms window makes one dwell -a sample, not a measurement — average ~20, which `chanscout` does not do today. +a sample, not a measurement. + +That last one is what `IRadio::ArmChannelBusy(window_us)` addresses: arm a +window where you reset your counters, read it when your dwell ends, and the +busy figure covers that window instead of a 2 ms slice of it. Both silicon +families implement it (Realtek arms CLM alone, MediaTek resets its channel +timers), and a window that something else disturbed — an NHM read re-arming the +shared engine, a retune, a read before it elapsed — comes back INVALID carrying +its reason rather than a plausible number. Own transmission is reported, not +corrected: the two families are biased in opposite directions by it. Which map +truncates and which merely inflates, and every measured number, live in each +`src//CLAUDE.md` and `docs/rx-spectrum-sensing.md`. Both are **emitted, not scored**: neither `ChannelScore` nor the hopset occupancy law reads them. Measured numbers, the generation matrix and the harness: `docs/rx-spectrum-sensing.md`. diff --git a/CMakeLists.txt b/CMakeLists.txt index 348b79ca..7e3f8da6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -837,6 +837,16 @@ target_link_libraries(chipstate PUBLIC devourer PRIVATE PkgConfig::libusb) # The in-tree caller for the carrier-sense gate split. Needs hardware, so it is # a tool rather than an add_test — tests/cca_gates_regcheck.sh drives it and # cross-checks the registers with chipstate. +# On-air driver for IRadio::ArmChannelBusy, through the public contract only +# (arm + GetChannelBusy). tests/busy_window_probe.sh runs its arms and asserts +# the separations; a spoiled window must come back INVALID with its reason, +# never as a plausible number. +add_executable(BusyWindowProbe + tests/busy_window_probe.cpp +) +target_include_directories(BusyWindowProbe PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/examples/common) +target_link_libraries(BusyWindowProbe PUBLIC devourer PRIVATE PkgConfig::libusb) + add_executable(CcaGatesProbe tests/cca_gates_probe.cpp ) @@ -1023,6 +1033,19 @@ target_compile_features(ChannelBusySelftest PRIVATE cxx_std_20) add_test(NAME channel_busy_math COMMAND ChannelBusySelftest) +# Headless guard for the CLM busy-window state machine (src/BusyWindow.h) with +# mocked register access — the clamp, the CLM-only arm sequence (NHM's half of +# the shared period dword must not move), the period-bounded ratio, and every +# way a window stops describing the caller's dwell. Those refusals are the +# point: on the 11AC map a spoiled window reads 3-4 points high, which no +# consumer can tell from a real channel. +add_executable(BusyWindowSelftest tests/busy_window_selftest.cpp) +target_include_directories(BusyWindowSelftest PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src) +target_compile_features(BusyWindowSelftest PRIVATE cxx_std_20) + +add_test(NAME busy_window_state COMMAND BusyWindowSelftest) + # Headless round-trip guard for the 802.11ax HE TX radiotap path # (src/RadiotapBuilder.cpp build_he + the shared Radiotap.c field-23 size entry # + src/ieee80211_radiotap.h HE masks): parse an HE spec -> build -> iterate -> diff --git a/docs/rx-spectrum-sensing.md b/docs/rx-spectrum-sensing.md index 66c476f3..a7dbdc27 100644 --- a/docs/rx-spectrum-sensing.md +++ b/docs/rx-spectrum-sensing.md @@ -316,8 +316,14 @@ rather than at the DIG loop. Jaguar3 is alive in both — and that is the same 8812CU, and the same code path, on which the counters were previously measured *inert* in a TX session with 4–20 ms quiet windows. The difference here is a 300 ms window. So window length, -not generation, is the live variable in that older result. Jaguar1 is -unmeasured. +not generation, is the live variable in that older result. + +Jaguar1 is measured now, through the armed window, and the answer is sharper +than "alive": CLM keeps counting while the adapter transmits, but reads LOW, +because it counts receive-side deferral and the receiver is deaf while the PA +is up. See "Own transmission is carried, not corrected" below for both +families' numbers — which is also why the reading carries `own_tx_in_window` +rather than an attempted correction. The generation coverage is the same as NHM's — the two ride one code path (`src/NhmReader.h`), so CLM lands wherever NHM does. Measured on Jaguar3 (8812CU, @@ -355,6 +361,113 @@ The facilities differ by generation but all three read the same fields: | Jaguar2 (8822BU/8821CU) | yes | classic AC (FA/CCA sampled by the DIG thread; same NHM map) | | Jaguar3 (8822CU/8822EU) | yes | newer BB — CCA 0x2c08, CCK-FA 0x1a5c, OFDM-FA 0x2d0x, IGI 0x1d70; NHM 0x1e60/0x1e40/0x1e44/0x2d40/0x2d4c | +## The armed busy window (`ArmChannelBusy`) + +CLM as read above is a **~2 ms sample**, once, because it rides NHM's window +and `read_nhm()` caps its ready poll at 15 ms. That is not a measurement of a +caller's dwell, and on bursty traffic it is bimodal rather than merely noisy. +Measured on an RTL8822BU against a 50 ms-on/450 ms-off interferer (true duty +~9%), one read per 300 ms: + +| estimator | samples | result | +|---|---|---| +| sampled, ~2 ms | 71 | **zero in 55 of them**, 61-65% in the rest; mean 9.0, sd 21.9 | +| MT7612U channel timers, 1 s | 12 | 8.1-9.5% **every** sample, sd 0.5 | + +Frames were decoded in nearly every one of those 71 windows, so the channel was +never actually free — the sample simply missed the burst 78% of the time. For a +ranker that reads "0% busy" as "emptiest", that is not lost information, it is +a wrong answer. + +`IRadio::ArmChannelBusy(window_us)` fixes the estimator rather than the +aggregation: CLM's period is its own field (the low half of the period dword) +and reaches 65535 ticks of 4 us, so the hardware can integrate the whole dwell. +Arm where the counters are reset, read at the end of the dwell. The MediaTek +timers already integrate between reads, so there "arming" is resetting them and +the mark; both families then answer the same question about the same slice of +time. `ArmChannelBusy` returns the window it actually armed — the period is +clamped to 240 ms, below the point where a saturated window cannot be told from +a wrapped one — and `ChannelBusy::window_us` reports the window each reading +spans. + +Same bench, 8812AU sensor, `REPS=6 DUTY_ON=50 DUTY_OFF=450 +tests/busy_window_probe.sh`: + +| estimator | mean | min | max | spread | +|---|---|---|---|---| +| armed 240 ms window | 10% | 0% | 19% | **19 points** | +| sampled ~2 ms | 24% | 0% | 73% | 73 points | + +The sampled column is not merely wider, it is wrong in the mean here too: six +2 ms samples caught a burst twice and reported 24% for a ~9% channel. Under a +STEADY load the two agree and the spread collapses (`REPS=6` with no duty +cycle: armed 71% spread 0, sampled 71% spread 6), which is why the bursty arm +is the one that decides anything. + +Both converge on the true duty in the MEAN, which is the other half of the +lesson: the median is the wrong fold here (it is 0 for the sampled path), and +the window is what makes a SINGLE dwell usable — which is what a +quick-connect decision has. + +### A window is only about the dwell if nothing else touched it + +Three things spoil one, all measured, and each makes the reading come back +invalid with a reason (`ChannelBusy::spoil`) rather than plausible: + +| spoiler | Jaguar1 (11AC) | Jaguar3 (JGR3) | +|---|---|---| +| an NHM read mid-window | survives, reads **+4 points high** (74.8 vs 70.9) | **destroyed** — returns the 2 ms re-arm (326 of 62500 ticks on the 250 ms window used for that measurement) | +| a retune mid-window | 60-62% where the channel was 71% | 44-47% where it was 61% | +| reading before it elapsed | the result register latches the PREVIOUS window; reads are non-destructive, so an early read is a stale number wearing a fresh timestamp | + +The first row is why the rule is enforced on every family and not only where it +fails loudly: `GetRxQuality()` calls `GetRxEnergy(with_nhm=true)`, so a +consumer polling link quality inside its own survey dwell spoils it without +touching the busy API at all. + +### Own transmission is carried, not corrected + +CLM counts receive-side deferral only, and a radio is deaf to the channel while +its own PA is up, so a transmitting sensor reads LOW. The MediaTek timers count +TX as busy, so the same session reads HIGH. Measured on one loaded channel, +sensor silent vs transmitting: + +| sensor | silent | transmitting | frames sent in-window | +|---|---|---|---| +| Jaguar1 RTL8812AU | 70.9% | **24-26%** | 765-1227 | +| Jaguar3 RTL8812CU | 60.9% | **18.4-18.6%** | 1503-3260 | + +`ChannelBusy::own_tx_in_window` and `own_tx_frames` say so; a ranker must not +mix a hot sample with a quiet one in either direction. This also corrects the +TX-session note further up: CLM is alive in a transmit session, but biased. + +### Saturation, and what is not measured + +The result and period fields are both 16 bits of 4 us ticks, so a window of +65535 ticks is the hardware maximum and `rpt == period` is how the vendor +reports a fully busy one. The armed period is capped at 60000 ticks (240 ms) +instead, which makes the ambiguity unreachable rather than handled: the count +is bounded by the period — measured, by arming 500 ticks and reading 270 ms +later, which returned 304-364 on all three Realtek families rather than +continuing to climb — so with a period below the field's range the counter +cannot wrap into a small number that would read as a quiet channel. + +What is NOT measured is a genuinely saturated channel. Two adapters could not +produce one here: a devourer flooder with no inter-frame gap holds this bench +at ~71%, and shortening the window to 1 ms only samples the gaps more finely +(30 reads spanned 67-80%, none at 100%). The `rpt == period` arithmetic is +covered headlessly instead (`tests/busy_window_selftest.cpp`, "saturated"), +where a result at or above the period reports 100%. + +### Coverage + +`ArmChannelBusy` works on Jaguar1, Jaguar2 and Jaguar3 (CLM) and on the +MT7612U (channel timers). Under one flooder on one channel the three Realtek +families and the MediaTek independently measured the same load at 61-71% — the +spread is antenna and receiver gain, not a units disagreement. Kestrel and the +RTL8733B return 0 from the arm (no CCX engine is wired up on either), and their +callers keep the sampled path. + ## Detecting a tone Run `DEVOURER_CW_TONE` on adapter A and `DEVOURER_RX_ENERGY_MS` on adapter B, same diff --git a/examples/common/DeviceSession.h b/examples/common/DeviceSession.h index a9a9f42c..1a8d179e 100644 --- a/examples/common/DeviceSession.h +++ b/examples/common/DeviceSession.h @@ -66,6 +66,13 @@ class DeviceSession { IRadio *device() const { return _dev.get(); } const std::shared_ptr &lock() const { return _lock; } + /* Drop this session's hold on the USB lock without touching the device. + * For a demo that must leave the process without running destructors (a + * detached RX thread it cannot join): the lock file is removed when the + * last holder goes, and one left behind makes the next run refuse the + * adapter. */ + void release_lock() { _lock.reset(); } + /* Explicit teardown for demos that have work to do after the adapter is * released (final statistics, a summary event). Idempotent; the destructor * calls it. */ diff --git a/src/BusyWindow.h b/src/BusyWindow.h new file mode 100644 index 00000000..6fb5dffd --- /dev/null +++ b/src/BusyWindow.h @@ -0,0 +1,175 @@ +/* BusyWindow — the CCX CLM measurement window as a state machine. + * + * IRadio::GetChannelBusy() as shipped reads whatever window the shared NHM + * read happened to arm: ~2 ms, once. That is a sample, not a measurement of + * the caller's dwell, and on bursty traffic it is bimodal — measured on an + * RTL8822BU against a 50 ms-on/450 ms-off interferer, one 2 ms read per 300 ms + * read ZERO in 55 of 71 windows and ~63% in the rest, while 300-400 frames + * were decoded in nearly every one of those windows. + * + * CLM's period field is its own (the low half of the period dword) and reaches + * 65535 ticks of 4 us, so the hardware can integrate a whole survey dwell in + * one window. This type owns that: arm at the point the caller resets its + * counters, read when the dwell ends. + * + * It also owns the three ways such a window stops describing what the caller + * thinks it describes. All three are measured, not theorised: + * + * 1. An NHM read inside the window. read_nhm() re-arms BOTH engines, and the + * damage is register-map-dependent: on the JGR3 map (Jaguar3) the long + * window is DESTROYED and the read returns the 2 ms re-arm (311-326 of + * 62500 ticks where a clean window read 38000), while on the 11AC map + * (Jaguar1/2) it survives but reads 3-4 points HIGH — the intruding + * measurement appears to add busy time. Neither is a reading about the + * window that was asked for. + * 2. A retune inside the window. The counter keeps running across it, so the + * result is a blend of two channels: measured 44-47% on a Jaguar3 and + * 60-62% on a Jaguar1 where the channel itself was 61% and 71% busy. + * 3. A read before the window has elapsed. The result register latches the + * LAST COMPLETED window, and reads are non-destructive (two reads without + * re-arming return identical values), so an early read silently returns a + * stale measurement rather than failing. + * + * Own transmission is carried rather than corrected. CLM counts receive-side + * deferral only: a transmitting sensor never counts its own airtime and is + * deaf while it transmits, so it reads LOW — measured 60.9% -> 18.5% on a + * Jaguar3 and 70.9% -> 25% on a Jaguar1, same channel, same interferer. The + * MediaTek channel timers are biased the other way (they count own TX as + * busy), which is exactly why this is a fact on the reading and not a fudge + * factor inside it. + * + * No hardware access of its own: the owner passes register accessors, so the + * whole state machine is reachable from a selftest with no radio. + */ +#ifndef DEVOURER_BUSY_WINDOW_H +#define DEVOURER_BUSY_WINDOW_H + +#include +#include + +#include "NhmReader.h" +#include "RxSense.h" + +namespace devourer { + +/* 4 us per tick, and the period field is 16 bits. */ +inline constexpr uint32_t kClmTickUs = 4; +/* The vendor reports rpt == period as 100% busy, so the top of the range + * cannot be told from a saturated window that wrapped. Cap below it: 60000 + * ticks = 240 ms, which still spans a survey dwell. */ +inline constexpr uint16_t kClmMaxPeriodTicks = 60000; +/* A floor rather than a refusal: below ~1 ms the caller is asking for what + * the sampled path already gives, and silently arming something shorter than + * the hardware can time would be worse than rounding up to it. The arm + * returns the window it actually programmed, so the caller can see it. */ +inline constexpr uint16_t kClmMinPeriodTicks = 250; + +inline uint16_t clm_period_for_us(uint32_t window_us) { + const uint32_t ticks = window_us / kClmTickUs; + if (ticks < kClmMinPeriodTicks) + return kClmMinPeriodTicks; + if (ticks > kClmMaxPeriodTicks) + return kClmMaxPeriodTicks; + return static_cast(ticks); +} + +class ClmWindow { +public: + using Read32 = std::function; + using SetBb = std::function; + + /* Arm a CLM-only window. Returns the window actually armed, in + * microseconds, which is the clamped period and NOT what the caller asked + * for. 0 means nothing was armed. `tx_submitted` is the device's running + * TX-submit counter, sampled so the read can tell whether this window was + * measured while the sensor itself was transmitting. */ + uint32_t arm(const NhmRegs ®s, uint32_t window_us, uint64_t tx_submitted, + const SetBb &set_bb) { + /* A zero window is a caller bug, not a request for the minimum: flooring + * it would arm hardware for a measurement nobody asked for and hand back + * a period they never chose. Nothing is armed and nothing is disturbed. */ + if (window_us == 0) + return 0; + period_ = clm_period_for_us(window_us); + arm_clm_only(regs, period_, set_bb); + armed_ = true; + spoil_ = BusySpoil::None; + tx_at_arm_ = tx_submitted; + return static_cast(period_) * kClmTickUs; + } + + /* Read an armed window. An unarmed, spoiled or not-yet-elapsed window + * yields an INVALID reading rather than a number: "no reading" and "quiet + * channel" are different facts and every consumer downstream acts on the + * difference. */ + ChannelBusy read(const NhmRegs ®s, uint64_t tx_submitted, + const Read32 &read32) { + ChannelBusy b; + if (!armed_) + return b; + armed_ = false; + + const ClmRead c = read_clm_only(regs, read32); + if (!c.ready) { + /* The ready bit is still clear: the window has not finished. The result + * register holds the PREVIOUS window, so reporting it would be a stale + * reading wearing this window's timestamp. */ + spoil_ = BusySpoil::NotElapsed; + last_spoil_ = spoil_; + b.spoil = spoil_; + return b; + } + if (spoil_ != BusySpoil::None) { + last_spoil_ = spoil_; + b.spoil = spoil_; + return b; + } + + b.valid = true; + b.source = BusySource::Clm; + b.valid_busy = true; + b.window_us = static_cast(period_) * kClmTickUs; + const uint32_t ticks = c.ticks > period_ ? period_ : c.ticks; + b.busy_pct = + static_cast((static_cast(ticks) * 100u + period_ / 2u) / + period_); + /* The counter is 64-bit and the field is 32. A window cannot hold four + * billion frames, but a counter that jumped (a reset, a wrap) would + * otherwise TRUNCATE into a small plausible count — and 3 frames reads as + * "barely transmitting" where the truth is "unknown". Saturate instead: + * the flag is what consumers act on, and it stays set. */ + const uint64_t sent = tx_submitted > tx_at_arm_ ? tx_submitted - tx_at_arm_ : 0; + b.own_tx_frames = sent > UINT32_MAX ? UINT32_MAX + : static_cast(sent); + b.own_tx_in_window = sent > 0; + last_spoil_ = BusySpoil::None; + return b; + } + + /* The owner calls these from the paths that are known to spoil a window. + * Cheap enough to call unconditionally: they do nothing when none is + * armed. */ + void note_nhm_read() { + if (armed_ && spoil_ == BusySpoil::None) + spoil_ = BusySpoil::Interrupted; + } + void note_retune() { + if (armed_ && spoil_ == BusySpoil::None) + spoil_ = BusySpoil::Retuned; + } + + bool armed() const { return armed_; } + /* Why the last read returned nothing (None when it returned a reading). */ + BusySpoil last_spoil() const { return last_spoil_; } + +private: + bool armed_ = false; + uint16_t period_ = 0; + uint64_t tx_at_arm_ = 0; + BusySpoil spoil_ = BusySpoil::None; + BusySpoil last_spoil_ = BusySpoil::None; +}; + +} // namespace devourer + +#endif /* DEVOURER_BUSY_WINDOW_H */ diff --git a/src/IRadio.h b/src/IRadio.h index d252e97b..05977c7a 100644 --- a/src/IRadio.h +++ b/src/IRadio.h @@ -593,6 +593,37 @@ class IRadio { * was never a correct discriminator. */ virtual devourer::ChannelBusy GetChannelBusy() { return {}; } + /* Arm a busy-airtime window of `window_us` and read it later with + * GetChannelBusy(), instead of taking whatever window the backend samples + * on its own. Returns the window ACTUALLY armed in microseconds (the + * hardware's granularity and limits clamp it, so a caller that needs to + * know its own denominator reads the return value, not its request); + * 0 means this backend cannot arm one and the caller should keep using the + * sampled GetChannelBusy() path. + * + * Why it exists: the sampled path is a ~2 ms hardware window on Realtek, + * which is a sample rather than a measurement of the caller's dwell. On a + * 50 ms-on/450 ms-off interferer, one such read per 300 ms measured ZERO in + * 55 of 71 windows and ~63% in the rest while frames were decoded in nearly + * every window; the same load read 8.1-9.5% per second on the MediaTek + * timers, which integrate. An armed window makes both families report busy + * airtime over the CALLER's window instead of over whatever each backend + * samples on its own — not an identical measurement: the Realtek window is + * hardware-timed and counts receive-side deferral, the MediaTek one is the + * host-measured arm-to-read gap and counts own TX. ChannelBusy::source, + * ::window_us and ::own_tx_in_window carry those differences. + * + * Contract: arm where the caller resets its own counters, read at the end + * of the dwell. A window is spoiled by an NHM read (IRtlRadio::GetRxEnergy + * with with_nhm, which re-arms the shared CCX engine), by a retune, and by + * reading before it has elapsed; the reading then comes back INVALID rather + * than plausible-but-wrong. Single control thread, like every other + * control-plane entry point. */ + virtual uint32_t ArmChannelBusy(uint32_t window_us) { + (void)window_us; + return 0; + } + /* --- Adapter health (see src/AdapterHealth.h; examples/doctor is the * reference consumer; the EFUSE probe is on IRtlRadio) --- */ diff --git a/src/IRtlRadio.h b/src/IRtlRadio.h index 2c523bc3..98ba4b5b 100644 --- a/src/IRtlRadio.h +++ b/src/IRtlRadio.h @@ -4,6 +4,9 @@ #include "IRadio.h" #include "AdapterHealth.h" /* EfuseStability */ +#include + +#include "BusyWindow.h" #include "RxSense.h" /* RxEnergy */ /* IRtlRadio is the Realtek-family extension of IRadio: the members whose @@ -83,9 +86,44 @@ class IRtlRadio : public IRadio { * COST + CONTENTION: see the IRadio declaration. This arms the ~2 ms NHM * window and consumes the same delta GetRxEnergy and GetRxQuality read. */ devourer::ChannelBusy GetChannelBusy() override { + /* Sampled OUTSIDE the CCX lock: GetTxStats may take a family lock of its + * own, and the ordering rule here is register lock -> CCX lock, never the + * reverse. */ + const uint64_t tx = GetTxStats().submitted; + devourer::ChannelBusy armed_reading; + bool was_armed = false; + const bool have_ccx = + with_ccx([&](const devourer::NhmRegs ®s, const Read32 &rd, + const SetBb &) { + if (!_busy_window.armed()) + return; + was_armed = true; + armed_reading = _busy_window.read(regs, tx, rd); + }); + if (have_ccx && was_armed) + return armed_reading; + /* Unarmed: the shipped sampled path, unchanged — GetRxEnergy takes its own + * locks, so it must not be called with the CCX lock held. */ return devourer::busy_from_rx_energy(GetRxEnergy(/*with_nhm=*/true)); } + /* IRadio::ArmChannelBusy on this family: a CLM-only window, armed without + * touching the NHM half of the shared engine. Implemented once here for the + * same reason GetChannelBusy is — every generation's answer is the same + * function of its CCX register map, which ccx_access() supplies. A + * generation with no CCX map returns 0 (not ported), and the caller keeps + * the sampled path. */ + uint32_t ArmChannelBusy(uint32_t window_us) override { + const uint64_t tx = GetTxStats().submitted; /* see GetChannelBusy */ + uint32_t armed = 0; + const bool have_ccx = + with_ccx([&](const devourer::NhmRegs ®s, const Read32 &, + const SetBb &wr) { + armed = _busy_window.arm(regs, window_us, tx, wr); + }); + return have_ccx ? armed : 0; + } + /* Perform `reads` fresh PHYSICAL EFUSE logical-map reads (each pass re-runs * the efuse-controller read sequence — not the cached shadow) and * cross-compare them. Dying silicon returns different content per read; @@ -174,6 +212,53 @@ class IRtlRadio : public IRadio { (void)edcca_disabled; return false; } + +protected: + using Read32 = std::function; + using SetBb = std::function; + + using CcxFn = std::function; + + /* Run `fn` with the generation's CCX register map and register accessors, + * holding every lock that access needs: the family's own register lock + * first (the CCX registers are read-modify-written by phy_set_bb_reg, and + * on the Jaguar3 the coex thread writes registers under _reg_mu), then + * busy_window_mutex(). A generation without a CCX engine leaves this false + * and its callers get "no reading" rather than a fabricated zero. + * + * It takes the locks rather than handing them out because the arm is FOUR + * register writes that must not interleave with another CCX user; lending + * bare accessors made every caller's sequence non-atomic. Protected: this + * is a family lending the base class its register access, not a + * register-poke API on the contract. */ + virtual bool with_ccx(const CcxFn & /*fn*/) { return false; } + + /* A generation calls these from the two paths measured to spoil an armed + * window: its NHM read (which re-arms the shared engine) and its retune. + * No-ops when nothing is armed, so they are safe to call unconditionally. + * + * THE CALLER MUST HOLD busy_window_mutex(), and for the NHM read it must + * hold it across the note AND the read itself. Otherwise the note can land + * before a concurrent arm sets its flag while the re-arm lands after it, + * which is precisely the destroyed window this reports — silently valid. */ + void busy_window_note_nhm_read() { _busy_window.note_nhm_read(); } + void busy_window_note_retune() { _busy_window.note_retune(); } + + /* Forget any armed window. Bring-up paths call it: a window armed before a + * re-Init describes a chip state that no longer exists, and leaving it + * armed would make the next unrelated GetChannelBusy() take the armed + * branch and report a stale period as if it were its own dwell. */ + void busy_window_reset() { _busy_window = devourer::ClmWindow{}; } + + /* Serialises the CCX engine: the armed window's state, the arm/read, and + * the NHM read that re-arms the same hardware. Ordering is always the + * family's register lock FIRST, then this one. */ + std::mutex &busy_window_mutex() { return _ccx_mu; } + + std::mutex _ccx_mu; + devourer::ClmWindow _busy_window; /* guarded by _ccx_mu */ + }; #endif /* IRTL_RADIO_H */ diff --git a/src/NhmReader.h b/src/NhmReader.h index 1c45948b..722f42bd 100644 --- a/src/NhmReader.h +++ b/src/NhmReader.h @@ -156,6 +156,53 @@ inline void read_nhm(const NhmRegs& r, uint8_t igi7, } } + +/* CLM alone, armed now and read later. + * + * read_nhm() above arms CLM and NHM on ONE window and polls a ready bit for at + * most 15 ms, so it can neither program nor read a window longer than that. + * CLM's period is its own field (the low half of the period dword) and reaches + * 65535 ticks of 4 us, which is long enough to integrate a survey dwell. These + * two split that into arm and read so the caller owns the window; the state + * machine that keeps such a window honest is devourer::ClmWindow + * (src/BusyWindow.h). + * + * NHM's half of the period dword and its trigger bit are deliberately not + * touched: the histogram is referenced to live IGI, so lengthening ITS window + * would make nhm_env less stable, not more. */ +inline void arm_clm_only( + const NhmRegs &r, uint16_t period, + const std::function &set_bb) { + /* cfg as read_nhm programs it — bit8 of ctrl is ccx_en, which both engines + * share. */ + set_bb(r.ctrl, 0xf00u, 0x3u); + set_bb(r.period, 0x0000ffffu, static_cast(period)); + /* Pulse CLM's trigger (bit0) 0->1. NHM's bit1 is left alone. */ + set_bb(r.ctrl, 0x1u, 0); + set_bb(r.ctrl, 0x1u, 1); +} + +struct ClmRead { + bool ready = false; /* the programmed window has completed */ + uint16_t ticks = 0; /* busy ticks, 4 us each */ +}; + +/* One read of the CLM result register. Non-destructive and latched: the + * register holds the last COMPLETED window until the next trigger, so two + * reads without an intervening arm return the same value (measured on + * Jaguar1/2/3). The caller must therefore know whether its own window has + * elapsed — an early read returns the PREVIOUS one, not an error. */ +inline ClmRead read_clm_only(const NhmRegs &r, + const std::function &read32) { + const uint32_t raw = read32(r.clm); + ClmRead c; + if (raw & (1u << 16)) { + c.ready = true; + c.ticks = static_cast(raw & 0xffffu); + } + return c; +} + } // namespace devourer #endif /* DEVOURER_NHM_READER_H */ diff --git a/src/RxSense.h b/src/RxSense.h index 24e6d935..ebd5fea2 100644 --- a/src/RxSense.h +++ b/src/RxSense.h @@ -108,6 +108,27 @@ enum class BusySource : uint8_t { ChTime = 2 /* MediaTek MAC channel timers: TX+RX+NAV+EIFS counted busy */ }; +/* Why an armed busy window produced no reading. Carried ON the reading + * because "this backend has no sensor" and "this window was spoiled" are + * different facts with different fixes, and a consumer that logs one as the + * other will chase the wrong bug. Named for what happened to the window, not + * for the facility that did it, so a second family's equivalent fits. + * + * Interrupted another measurement re-armed the shared engine mid-window + * (on Realtek, an NHM read: destructive on the JGR3 map, a 3-4 + * point overcount on the 11AC map). + * Retuned the radio changed channel mid-window, so the count is a + * blend of two channels. + * NotElapsed read before the window finished. The hardware latches the + * PREVIOUS window's result, so this would be a stale reading + * wearing a fresh timestamp. */ +enum class BusySpoil : uint8_t { + None = 0, + Interrupted, + Retuned, + NotElapsed +}; + /* ChannelBusy — the vendor-NEUTRAL frame-free channel-occupancy reading, and * the only energy evidence src/chanmig/ and src/hopset/ can ask an arbitrary * backend for. @@ -120,7 +141,9 @@ enum class BusySource : uint8_t { * * Read-and-clear DELTA semantics, like RxEnergy: each call returns the window * since the previous call on that backend, so two pollers on one radio steal - * each other's counts. + * each other's counts. IRadio::ArmChannelBusy replaces that implicit window + * with an explicit one: after an arm, the next reading covers arm-to-read and + * nothing else, and window_us says so. * * Every field carries a validity flag. A backend that cannot answer reports * valid=false, NEVER a zero reading — "quiet channel" and "no sensor" are @@ -167,6 +190,27 @@ struct ChannelBusy { * unowned counter is not available for reuse. */ bool valid_energy = false; uint8_t energy_pct = 0; + + /* Did the radio TRANSMIT inside the measured window, and how many frames. + * + * Carried rather than corrected, because the two families are biased in + * OPPOSITE directions and only the consumer knows which it can live with: + * - Clm counts receive-side deferral only, and a transmitting radio is + * deaf to the channel while its own PA is up, so the reading comes back + * LOW. Measured on one 61%-busy channel: 60.9% silent, 18.4-18.6% while + * the sensor transmitted (Jaguar3); 70.9% -> 24.4-25.8% on a Jaguar1. + * - ChTime counts TX, RX, NAV and EIFS alike, so the same session reads + * HIGH by its own airtime. + * A ranker comparing channels must not mix a hot sample with a quiet one in + * either direction. window_us with own_tx_in_window set is a sample that + * was taken, not a channel that was measured. */ + bool own_tx_in_window = false; + uint32_t own_tx_frames = 0; + + /* Set when an ARMED window came back unusable (valid stays false). None on + * a backend that simply has no sensor — that is the absence of a reading, + * not a spoiled one. */ + BusySpoil spoil = BusySpoil::None; }; /* RxEnergy -> ChannelBusy. Pure. CLM and NHM-env are the only two fields in diff --git a/src/jaguar1/CLAUDE.md b/src/jaguar1/CLAUDE.md index 33f17efd..9d778218 100644 --- a/src/jaguar1/CLAUDE.md +++ b/src/jaguar1/CLAUDE.md @@ -89,7 +89,16 @@ rate-independent, so neither can implement the other. `GetRxEnergy(with_nhm=true)` runs the shared CCX window (`src/NhmReader.h`) on the 11AC register map — the same map validated on the Jaguar2 8822BU, so CLM -lands here with no Jaguar1-specific code. **Unmeasured on this generation.** +lands here with no Jaguar1-specific code. **Measured on an RTL8812AU** +(`docs/rx-spectrum-sensing.md`): a 240 ms armed window read 70.6-70.9% against +a flooder a MediaTek adapter independently measured, 0.1-1.0% on a quiet +channel, so `busy_airtime_measured` is true. + +Window behaviour matches the Jaguar2 and NOT the Jaguar3: an NHM read inside an +armed window leaves it readable but 4 points HIGH (74.8 vs 70.9), where the +JGR3 map truncates it to the 2 ms re-arm. Both are spoiled — `ClmWindow` +invalidates either way — but only one of them looks broken, which is why the +rule is enforced per family rather than per symptom. The `nhm_env` reduction is referenced to the live IGI, so what matters is how far DIG may walk it. `PhydmWatchdog` clamps to `0x1c`–`0x2a` (14 steps, diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index 21681e9e..48b109d1 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -97,6 +97,13 @@ RtlJaguarDevice::RtlJaguarDevice(RtlAdapter device, Logger_t logger, _logger{logger} {} void RtlJaguarDevice::InitWrite(SelectedChannel channel) { + /* A window armed before a (re-)bring-up describes a chip state that no + * longer exists; leaving it armed would make the next unrelated + * GetChannelBusy() take the armed branch and report a stale period. */ + { + std::lock_guard ccx(busy_window_mutex()); + busy_window_reset(); + } std::optional configured_arm_generation; JaguarScopeExit rollback([&] { if (!configured_arm_generation) @@ -353,8 +360,16 @@ RxEnergy RtlJaguarDevice::GetRxEnergy(bool with_nhm) { /* NHM 12-bucket power histogram (frame-free, 11AC register map). Skipped * when the caller did not ask: it arms a ~2 ms window and polls at 1 ms - * granularity, which dwarfs the register reads above. */ - if (with_nhm) + * granularity, which dwarfs the register reads above. + * + * Under the CCX lock, together with the note: this read RE-ARMS the shared + * engine, so it spoils any window ArmChannelBusy set up, and the note must + * not be able to land before a concurrent arm while the re-arm lands after + * it — that ordering is what turns a destroyed window into a valid-looking + * reading. */ + if (with_nhm) { + std::lock_guard ccx(busy_window_mutex()); + busy_window_note_nhm_read(); devourer::read_nhm( devourer::nhm_regs_11ac(), e.igi, [this](uint16_t a) { return _device.rtw_read(a); }, @@ -362,6 +377,7 @@ RxEnergy RtlJaguarDevice::GetRxEnergy(bool with_nhm) { _device.phy_set_bb_reg(a, m, v); }, e); + } /* Active absolute floor: the debug-port measurement wedges live RX, so * it is NOT re-run here — GetRxEnergy just surfaces the RX-idle CAL taken at @@ -1601,6 +1617,13 @@ size_t RtlJaguarDevice::build_tx_block(const uint8_t *packet, size_t length, void RtlJaguarDevice::Init(Action_ParsedRadioPacket packetProcessor, SelectedChannel channel) { + /* A window armed before a (re-)bring-up describes a chip state that no + * longer exists; leaving it armed would make the next unrelated + * GetChannelBusy() take the armed branch and report a stale period. */ + { + std::lock_guard ccx(busy_window_mutex()); + busy_window_reset(); + } std::optional configured_arm_generation; JaguarScopeExit rollback([&] { if (!configured_arm_generation) @@ -1898,6 +1921,13 @@ void RtlJaguarDevice::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { } void RtlJaguarDevice::SetMonitorChannel(SelectedChannel channel) { + /* A window armed before this retune would integrate across the channel + * change and report the blend as one channel's occupancy. */ + /* Held ACROSS the tune, not just around the note: a window armed in the gap + * between the two would integrate across the channel change and read back + * valid. Ordering is the family's register lock first, then this one. */ + std::lock_guard ccx(busy_window_mutex()); + busy_window_note_retune(); /* Keep the device-level channel state current: send_packet's 5GHz * CCK->OFDM clamp keys off _channel.Channel. Before this assignment * existed, _channel was never written anywhere — the clamp read an @@ -1923,6 +1953,13 @@ int RtlJaguarDevice::GetRxPathMask() { } void RtlJaguarDevice::FastRetune(uint8_t channel, bool cache_rf) { + /* A window armed before this retune would integrate across the channel + * change and report the blend as one channel's occupancy. */ + /* Held ACROSS the tune, not just around the note: a window armed in the gap + * between the two would integrate across the channel change and read back + * valid. Ordering is the family's register lock first, then this one. */ + std::lock_guard ccx(busy_window_mutex()); + busy_window_note_retune(); if (_radioManagement->fast_retune(channel, cache_rf)) { _channel.Channel = channel; return; @@ -1935,6 +1972,11 @@ void RtlJaguarDevice::FastRetune(uint8_t channel, bool cache_rf) { } void RtlJaguarDevice::FastSetBandwidth(ChannelWidth_t bw) { + /* A bandwidth change re-clocks the front end, so a window armed before it + * was measuring a different receiver — the same argument as a retune. Held + * across the change, not just noted before it. */ + std::lock_guard ccx(busy_window_mutex()); + busy_window_note_retune(); if (_radioManagement->fast_set_bandwidth(bw)) { _channel.ChannelWidth = bw; return; @@ -2124,10 +2166,14 @@ devourer::AdapterCaps RtlJaguarDevice::GetAdapterCaps() { c.tx_chains = chains; c.rx_chains = chains; c.per_chain_rssi = chains >= 2; - /* CCX CLM via NhmReader's 11AC map — the same map validated on the Jaguar2, - * but unmeasured on this family, so _measured stays false. */ + /* CCX CLM via NhmReader's 11AC map, now measured on this family too + * (RTL8812AU, docs/rx-spectrum-sensing.md): a 240 ms armed window read + * 70.6-70.9% against a flooder that a MediaTek adapter independently + * measured, 0.1-1.0% quiet, and it behaves like the Jaguar2 in every + * window arm — period-bounded, latched, spoiled by an NHM read as a 4-point + * overcount rather than the JGR3 map's truncation. */ c.busy_airtime_ok = true; - c.busy_airtime_measured = false; + c.busy_airtime_measured = true; c.rx_energy_ok = true; c.bw_mask = devourer::bw_mask_for_generation(c.generation); /* 5/10 MHz narrowband on the 8812 die (8812AU/8811AU) and the 8814AU. Both diff --git a/src/jaguar1/RtlJaguarDevice.h b/src/jaguar1/RtlJaguarDevice.h index 27892597..50d3bc8b 100644 --- a/src/jaguar1/RtlJaguarDevice.h +++ b/src/jaguar1/RtlJaguarDevice.h @@ -434,6 +434,22 @@ class RtlJaguarDevice : public IRtlRadio { bool la_capture_wedged() const { return _la && _la->is_wedged(); } private: + /* This generation's CCX map and register access, under its locks — see + * IRtlRadio::with_ccx. Private: the base class calls it, nobody else. */ + bool with_ccx(const CcxFn &fn) override { + /* No family-wide register lock on this generation; the CCX lock is + * the serialisation. */ + std::lock_guard ccx(busy_window_mutex()); + const Read32 rd = [this](uint16_t a) { + return _device.rtw_read(a); + }; + const SetBb wr = [this](uint16_t a, uint32_t m, uint32_t v) { + _device.phy_set_bb_reg(a, m, v); + }; + fn(devourer::nhm_regs_11ac(), rd, wr); + return true; + } + /* Programs 0x520[14]/[15] and, for the EDCCA gate only, the BB thresholds * at 0x8a4. SetCcaMode is apply_cca(d, d) and writes exactly what it * wrote before the split existed. */ diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index f2a07dbf..7d893fe4 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -445,6 +445,13 @@ void RtlJaguar2Device::ClearAmpduMode() { SetAmpduMode(devourer::AmpduMode{}); } void RtlJaguar2Device::Init(Action_ParsedRadioPacket packetProcessor, SelectedChannel channel) { + /* A window armed before a (re-)bring-up describes a chip state that no + * longer exists; leaving it armed would make the next unrelated + * GetChannelBusy() take the armed branch and report a stale period. */ + { + std::lock_guard ccx(busy_window_mutex()); + busy_window_reset(); + } _channel = channel; bring_up(channel); @@ -676,6 +683,13 @@ void RtlJaguar2Device::stop_dig() { } void RtlJaguar2Device::InitWrite(SelectedChannel channel) { + /* A window armed before a (re-)bring-up describes a chip state that no + * longer exists; leaving it armed would make the next unrelated + * GetChannelBusy() take the armed branch and report a stale period. */ + { + std::lock_guard ccx(busy_window_mutex()); + busy_window_reset(); + } _channel = channel; /* TX shares the full cold bring-up (config_trx_mode enables the TX antenna * paths, enable_rx sets CR MACTXEN). The chip transmits at its @@ -889,8 +903,15 @@ void RtlJaguar2Device::StopContinuousTx() { } void RtlJaguar2Device::SetMonitorChannel(SelectedChannel channel) { + /* A window armed before this retune would integrate across the channel + * change and report the blend as one channel's occupancy. */ /* Serialize against the thermal-track tick's RF-window read. */ std::lock_guard lk(_reg_mu); + /* Held ACROSS the tune, not just around the note: a window armed in the gap + * between the two would integrate across the channel change and read back + * valid. Ordering is the family's register lock first, then this one. */ + std::lock_guard ccx(busy_window_mutex()); + busy_window_note_retune(); _channel = channel; /* Retune the RF/BB to the new channel. set_channel_bw is a pure tune (RF18 + * bandwidth registers) — no per-channel LCK/IQK/TX-power — so it is cheap @@ -914,9 +935,21 @@ void RtlJaguar2Device::SetMonitorChannel(SelectedChannel channel) { void RtlJaguar2Device::FastRetune(uint8_t channel, bool cache_rf) { if (channel == _channel.Channel) - return; + return; /* no tune, so nothing to spoil — the note goes after this */ + /* A window armed before this retune would integrate across the channel + * change and report the blend as one channel's occupancy. */ + { + std::lock_guard ccx(busy_window_mutex()); + busy_window_note_retune(); + } + /* Serialize against the thermal-track tick's RF-window read. */ std::lock_guard lk(_reg_mu); + /* Held ACROSS the tune, not just around the note: a window armed in the gap + * between the two would integrate across the channel change and read back + * valid. Ordering is the family's register lock first, then this one. */ + std::lock_guard ccx(busy_window_mutex()); + busy_window_note_retune(); const bool band_change = (_channel.Channel <= 14) != (channel <= 14); if (_hal.fast_retune(channel, static_cast(_channel.ChannelWidth), _channel.ChannelOffset, cache_rf)) { @@ -940,6 +973,11 @@ void RtlJaguar2Device::FastRetune(uint8_t channel, bool cache_rf) { void RtlJaguar2Device::FastSetBandwidth(ChannelWidth_t bw) { { std::lock_guard lk(_reg_mu); + /* A bandwidth change re-clocks the front end, so a window armed before it + * was measuring a different receiver — the same argument as a retune. Held + * across the change, not just noted before it. */ + std::lock_guard ccx(busy_window_mutex()); + busy_window_note_retune(); if (_hal.fast_set_bandwidth(static_cast(bw))) { _channel.ChannelWidth = bw; return; @@ -976,7 +1014,12 @@ RxEnergy RtlJaguar2Device::GetRxEnergy(bool with_nhm) { RxEnergy e = _hal.last_energy(); /* The scalars above are a cached snapshot (no IO); the NHM below is the * expensive part, so it is the caller's choice. */ - if (with_nhm) + /* Under the CCX lock together with the note — see the Jaguar1 comment: the + * read re-arms the shared engine, so an armed busy window is spoiled by it + * and the pair must be atomic against a concurrent arm. */ + if (with_nhm) { + std::lock_guard ccx(busy_window_mutex()); + busy_window_note_nhm_read(); devourer::read_nhm( devourer::nhm_regs_11ac(), e.igi, [this](uint16_t a) { return _device.rtw_read(a); }, @@ -984,6 +1027,7 @@ RxEnergy RtlJaguar2Device::GetRxEnergy(bool with_nhm) { _device.phy_set_bb_reg(a, m, v); }, e); + } /* DEVOURER_RX_NOISE_FLOOR — active/frame-free absolute floor. The * vendor phydm_idle_noise_measure_ac: the BB maintains an idle-time power diff --git a/src/jaguar2/RtlJaguar2Device.h b/src/jaguar2/RtlJaguar2Device.h index 0e5735be..3c6fbd20 100644 --- a/src/jaguar2/RtlJaguar2Device.h +++ b/src/jaguar2/RtlJaguar2Device.h @@ -208,6 +208,21 @@ class RtlJaguar2Device : public IRtlRadio { bool la_capture_wedged() const { return _la && _la->is_wedged(); } private: + /* This generation's CCX map and register access, under its locks — see + * IRtlRadio::with_ccx. Private: the base class calls it, nobody else. */ + bool with_ccx(const CcxFn &fn) override { + std::lock_guard reg(_reg_mu); + std::lock_guard ccx(busy_window_mutex()); + const Read32 rd = [this](uint16_t a) { + return _device.rtw_read(a); + }; + const SetBb wr = [this](uint16_t a, uint32_t m, uint32_t v) { + _device.phy_set_bb_reg(a, m, v); + }; + fn(devourer::nhm_regs_11ac(), rd, wr); + return true; + } + /* Golden-init replay (DEVOURER_REPLAY_WSEQ) — applied at the end of both * Init and InitWrite (see the definition for semantics). */ void apply_replay_wseq(); diff --git a/src/jaguar3/CLAUDE.md b/src/jaguar3/CLAUDE.md index 5f624cb7..278e9301 100644 --- a/src/jaguar3/CLAUDE.md +++ b/src/jaguar3/CLAUDE.md @@ -106,6 +106,15 @@ same TSSI reshape as its offset slope). ## CCX energy sensing (`clm` / `nhm_env`) +**An armed busy window (`ArmChannelBusy`) is DESTROYED by an NHM read on this +map.** Measured on an RTL8812CU: a clean 240 ms window read 60.4-61.6% under +load, while the same window with one `GetRxEnergy(with_nhm=true)` mid-way came +back as the 2 ms re-arm (311-326 of 62500 ticks). The 11AC families survive the +same intrusion and merely read 3-4 points high, so this is the generation where +the shared-engine rule is not optional. `GetRxQuality()` takes that NHM read, +which makes the trap easy to spring from a caller that never touches the busy +API. + `GetRxEnergy(with_nhm=true)` runs the shared CCX window (`src/NhmReader.h`) on the JGR3 register map (CLM period is the low half of `0x1e40`, trigger `0x1e60[0]`, ready+result `0x2d88`); on-air validated on an RTL8812CU, ch100. diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index f15b143a..0865fa74 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -79,6 +79,13 @@ struct WriteBatchScope { void RtlJaguar3Device::Init(Action_ParsedRadioPacket packetProcessor, SelectedChannel channel) { + /* A window armed before a (re-)bring-up describes a chip state that no + * longer exists; leaving it armed would make the next unrelated + * GetChannelBusy() take the armed branch and report a stale period. */ + { + std::lock_guard ccx(busy_window_mutex()); + busy_window_reset(); + } _channel = channel; _rx_wanted = true; /* No WriteBatchScope here (yet): the pipelined bring-up is validated on @@ -751,6 +758,13 @@ void RtlJaguar3Device::Stop() { } void RtlJaguar3Device::InitWrite(SelectedChannel channel) { + /* A window armed before a (re-)bring-up describes a chip state that no + * longer exists; leaving it armed would make the next unrelated + * GetChannelBusy() take the armed branch and report a stale period. */ + { + std::lock_guard ccx(busy_window_mutex()); + busy_window_reset(); + } _channel = channel; /* Concurrent TX+RX intent (DEVOURER_TX_WITH_RX / a later StartRxLoop on this * bring-up): enable the RX path at the same point in the sequence Init does @@ -1230,13 +1244,20 @@ RxEnergy RtlJaguar3Device::GetRxEnergy(bool with_nhm) { * ~2 ms measurement window before the FA-counter reset below (0x1eb4[25] also * clears BB HW counters). Holds _reg_mu across the short wait — tolerable at * the emitter's >=100 ms cadence vs the coex thread's ~2 s tick. */ - if (with_nhm) + /* Under the CCX lock together with the note, and INSIDE _reg_mu (the + * ordering every other CCX user takes): this read re-arms the shared engine, + * so it destroys an armed busy window on this map — the note must be atomic + * with the re-arm or a destroyed window reads back valid. */ + if (with_nhm) { + std::lock_guard ccx(busy_window_mutex()); + busy_window_note_nhm_read(); devourer::read_nhm( devourer::nhm_regs_jgr3(), e.igi, rd, [this](uint16_t a, uint32_t m, uint32_t v) { _device.phy_set_bb_reg(a, m, v); }, e); + } /* Reset: CCK FA 0x1a2c[15:14] 0->2, CCK CCA 0x1a2c[13:12] 0->2, then OFDM * CCA/FA (phydm_reset_bb_hw_cnt jgr3: 0x1eb4[25] 1->0, wrapped by the @@ -1348,12 +1369,20 @@ void RtlJaguar3Device::SetCcaMode(bool disabled) { } void RtlJaguar3Device::SetMonitorChannel(SelectedChannel channel) { + /* A window armed before this retune would integrate across the channel + * change and report the blend as one channel's occupancy. */ _phydm.on_channel_change(); /* Serialize against the coex thread's housekeeping tick (and any concurrent * FastRetune) — channel config is register RMW. Init/InitWrite call the * radio-management core directly (no lock needed: the coex thread isn't * running yet), so locking here cannot self-deadlock. */ std::lock_guard lk(_reg_mu); + /* Held ACROSS the tune, not just around the note: a window armed in the gap + * between the two would integrate across the channel change and read back + * valid. Ordering is the family's register lock first, then this one. */ + std::lock_guard ccx(busy_window_mutex()); + busy_window_note_retune(); + const bool ch_changed = channel.Channel != _channel.Channel; _channel = channel; _radioManagement.set_channel_bwmode(channel.Channel, channel.ChannelOffset, @@ -1379,7 +1408,14 @@ void RtlJaguar3Device::SetMonitorChannel(SelectedChannel channel) { void RtlJaguar3Device::FastRetune(uint8_t channel, bool cache_rf) { std::lock_guard lk(_reg_mu); if (channel == _channel.Channel) - return; + return; /* no tune, so nothing to spoil — the note goes after this */ + /* A window armed before this retune would integrate across the channel + * change and report the blend as one channel's occupancy. */ + { + std::lock_guard ccx(busy_window_mutex()); + busy_window_note_retune(); + } + const bool band_change = (_channel.Channel <= 14) != (channel <= 14); if (_radioManagement.fast_retune(channel, _channel.ChannelOffset, _channel.ChannelWidth, cache_rf)) { @@ -1403,7 +1439,14 @@ void RtlJaguar3Device::FastRetune(uint8_t channel, bool cache_rf) { void RtlJaguar3Device::FastSetBandwidth(ChannelWidth_t bw) { std::lock_guard lk(_reg_mu); if (bw == _channel.ChannelWidth) - return; + return; /* no reconfiguration, so nothing to spoil */ + /* A bandwidth change reconfigures the front end, so a window armed before + * it was measuring a different receiver — the same argument as a retune, + * and the full path below tunes the RF outright. */ + { + std::lock_guard ccx(busy_window_mutex()); + busy_window_note_retune(); + } auto in_set = [](ChannelWidth_t b) { return b == CHANNEL_WIDTH_20 || b == CHANNEL_WIDTH_5 || b == CHANNEL_WIDTH_10; diff --git a/src/jaguar3/RtlJaguar3Device.h b/src/jaguar3/RtlJaguar3Device.h index fd4b3035..f1b71c96 100644 --- a/src/jaguar3/RtlJaguar3Device.h +++ b/src/jaguar3/RtlJaguar3Device.h @@ -225,6 +225,21 @@ class RtlJaguar3Device : public IRtlRadio { bool should_stop = false; private: + /* This generation's CCX map and register access, under its locks — see + * IRtlRadio::with_ccx. Private: the base class calls it, nobody else. */ + bool with_ccx(const CcxFn &fn) override { + std::lock_guard reg(_reg_mu); + std::lock_guard ccx(busy_window_mutex()); + const Read32 rd = [this](uint16_t a) { + return _device.rtw_read(a); + }; + const SetBb wr = [this](uint16_t a, uint32_t m, uint32_t v) { + _device.phy_set_bb_reg(a, m, v); + }; + fn(devourer::nhm_regs_jgr3(), rd, wr); + return true; + } + /* Parse one send_packet-contract buffer (radiotap + 802.11) and build its * TXDMA block — 48-byte descriptor, pkt_offset×8 pad, frame — at `out` * (zeroed, sized desc + pad + frame by the caller). Performs the per-packet diff --git a/src/mt7612u/CLAUDE.md b/src/mt7612u/CLAUDE.md index 6cc1ee90..694b4f58 100644 --- a/src/mt7612u/CLAUDE.md +++ b/src/mt7612u/CLAUDE.md @@ -11,14 +11,34 @@ The subtree-specific facts: the arming configuration is mt76's `mt76x02_mac_cc_reset()` exactly, which counts TX as busy, so a transmitting radio measures its own airtime and must read inside a quiet window. Timers are armed on both the RX and the transmit-only path and re-armed on every live -retune. `ChannelBusy::energy_pct` is left invalid here because the only +retune — but arming is not the same as counting: with the receiver down the +idle timer advances and the busy one does not, so a transmit-only session gets +NO reading rather than the plausible 0% it used to return. `ChannelBusy::energy_pct` is left invalid here because the only candidate counter, `MT_RX_STAT_1`'s false-CCA field, is owned by `mt7612u_phy_tick()`'s AGC loop. -**Not validated on hardware** — no MT7612U was available. `busy_airtime_ok` is -true, `busy_airtime_measured` is false. Two things to measure when one is on -the bench: that busy/idle track real occupancy beyond repetition noise, and -that polling at dwell cadence does not disturb `phy_tick`'s gain tracking. For +**Validated on hardware** (`docs/rx-spectrum-sensing.md`): 64.0-64.3% under a +flooder two Realtek generations put at 61-63%, 0.0% on a quiet channel, and +8.1-9.5% per second on a ~9%-duty bursty interferer — the arm the Realtek 2 ms +sample fails. `busy_airtime_measured` is now true. + +Two facts that measurement added. First, the reading **requires a running +receiver**: with RX down the idle timer still advances, so busy+idle is +non-zero and the ratio is a perfectly plausible 0% — a fabricated zero that a +channel ranker reads as "emptiest". `GetChannelBusy` and `ArmChannelBusy` both +refuse unless `_rx_active`. Second, bring-up does not reliably complete while +the channel is already saturated (the arm then never becomes available, and the +MCU times out), so in a two-adapter test bring this one up BEFORE the +interferer. + +`ArmChannelBusy` here resets the timers and the interval mark +(`mt7612u_link_stats_start`), so the window is exactly the caller's arm-to-read +gap and is reported that way in `window_us`. The mark is stamped at arm rather +than zeroed — zeroing made the first read after an arm report `window_us=0`, +i.e. a percentage with no denominator. + +Still to measure: that polling at dwell cadence does not disturb +`phy_tick`'s gain tracking. For the latter, reuse the tick's own controlled benchmark — with and without the poller, against a steady peer — reading the full figures, control arm included, from the `mt7612u_phy_tick` doc comment rather than a copy of its headline diff --git a/src/mt7612u/Mt7612uRadio.cpp b/src/mt7612u/Mt7612uRadio.cpp index 2cdba3e5..c1462af7 100644 --- a/src/mt7612u/Mt7612uRadio.cpp +++ b/src/mt7612u/Mt7612uRadio.cpp @@ -348,9 +348,15 @@ void Mt7612uRadio::InitWrite(SelectedChannel channel) { * receiver wedge. */ if (mt7612u_start(_dev) != 0) throw std::runtime_error("MT7612U MAC start failed"); - /* Arm the channel timers here too. A transmit-only session has no RX - * loop to do it, and TX-side quiet-window sensing is exactly a caller - * that would otherwise read timers nobody configured. */ + /* Arm the channel timers here too, so the MIB block and the timers are + * in a known state for this session. + * + * It does NOT make a busy reading available on a transmit-only session: + * measured with the receiver down, the idle timer advances while the + * busy one does not, so the ratio is a plausible 0% for a channel that + * was never listened to. GetChannelBusy therefore refuses unless the + * receiver is running, and a TX-side caller that needs occupancy has to + * bring up RX for the measurement. */ mt7612u_link_stats_start(_dev); } catch (...) { failed = std::current_exception(); @@ -578,8 +584,12 @@ void Mt7612uRadio::SetMonitorChannel(SelectedChannel channel) { /* Re-arm the channel timers: they are read-and-clear and their interval mark * persists, so without this the first sample after a retune would carry the * PREVIOUS channel's airtime and an interval spanning the retune, and report - * it as a valid reading for the new channel. */ + * it as a valid reading for the new channel. A window armed BEFORE this + * retune is spoiled either way — re-arming makes the next reading honest, + * it does not make the old one about one channel. */ mt7612u_link_stats_start(_dev); + if (_busy_armed) + _busy_spoiled = true; } SelectedChannel Mt7612uRadio::GetSelectedChannel() { @@ -829,13 +839,36 @@ uint64_t Mt7612uRadio::ReadTsf() { /* Busy airtime from the MAC channel timers — the MediaTek half of the neutral * IRadio::GetChannelBusy contract. * - * NOT hardware-validated: no MT7612U was available when this was written. The - * register pair and its arming configuration match mt76's - * mt76x02_mac_cc_reset() exactly, and the C accessor it calls is the one this - * port already runs, but no arm-vs-quiet separation has been measured on air. - * GetAdapterCaps().busy_airtime_measured stays false until it has been. */ + * MEASURED on air (see docs/rx-spectrum-sensing.md): against a devourer + * flooder on one channel this read 64.0-64.3% where two Realtek generations + * independently measured the same load at 61-63%, 0.0% on a quiet channel, + * and 8.1-9.5% per second on a 50 ms-on/450 ms-off interferer whose true duty + * was ~9%. GetAdapterCaps().busy_airtime_measured is true accordingly. + * + * REQUIRES A RUNNING RECEIVER. The MAC only accumulates busy time while the + * receiver is on: with RX down the idle timer still advances, so busy+idle is + * non-zero and the ratio comes out a perfectly plausible 0% — measured, and + * exactly the fabricated zero this contract exists to prevent. For a channel + * ranker 0% means "emptiest", so that zero does not merely lose information, + * it steers the choice onto the busiest channel. No RX, no reading. */ devourer::ChannelBusy Mt7612uRadio::GetChannelBusy() { uint32_t busy = 0, idle = 0, interval_us = 0; + if (!_rx_active.load(std::memory_order_acquire)) + return {}; + { + /* An armed window that a retune ran through counts across the channel + * change, exactly as the Realtek one does, so it is refused with its + * reason rather than reported as this channel's occupancy. */ + std::lock_guard lock(_mu); + if (_busy_armed && _busy_spoiled) { + _busy_armed = false; + _busy_spoiled = false; + devourer::ChannelBusy spoiled; + spoiled.spoil = devourer::BusySpoil::Retuned; + return spoiled; + } + _busy_armed = false; + } { /* The 1 Hz tick thread holds _mu inside mt7612u_phy_tick, which issues MCU * commands; serialising here keeps register access single-file the way @@ -851,6 +884,29 @@ devourer::ChannelBusy Mt7612uRadio::GetChannelBusy() { return devourer::busy_from_ch_time(busy, idle, interval_us); } +/* IRadio::ArmChannelBusy on MediaTek: the channel timers already integrate + * over the whole interval between reads, so "arming" is resetting them and + * the interval mark — which is what mt7612u_link_stats_start() does. The + * window therefore ends up exactly as long as the caller's own arm-to-read + * gap, and is reported that way (window_us from the host-measured interval) + * rather than as the requested length. + * + * Returns the REQUESTED window, which is the honest answer here: nothing is + * programmed into hardware, so nothing clamps it. A caller that needs the + * window that actually elapsed reads ChannelBusy::window_us. */ +uint32_t Mt7612uRadio::ArmChannelBusy(uint32_t window_us) { + if (!_rx_active.load(std::memory_order_acquire)) + return 0; + std::lock_guard lock(_mu); + if (!_dev) + return 0; + if (mt7612u_ch_time_arm(_dev) != 0) + return 0; + _busy_armed = true; + _busy_spoiled = false; + return window_us; +} + devourer::TxStats Mt7612uRadio::GetTxStats() { devourer::TxStats out{}; @@ -1099,11 +1155,13 @@ devourer::AdapterCaps Mt7612uRadio::GetAdapterCaps() { c.ldpc_rx_flag = true; /* the RXWI carries the per-frame LDPC bit */ c.per_chain_rssi = true; /* Busy airtime from the MAC channel timers (MT_CH_BUSY / MT_CH_IDLE), via - * mt7612u_ch_time(). Implemented, NOT measured: no MT7612U was on the bench, - * so no arm-vs-quiet separation has been taken on air. There are no phydm + * mt7612u_ch_time(). Now measured on air: 64.0-64.3% under a flooder two + * Realtek generations put at 61-63%, 0.0% quiet, 8.1-9.5% per second on a + * ~9%-duty bursty interferer (docs/rx-spectrum-sensing.md). The reading + * needs a running receiver — see GetChannelBusy. There are no phydm * counters here at all, so rx_energy_ok is structurally false. */ c.busy_airtime_ok = true; - c.busy_airtime_measured = false; + c.busy_airtime_measured = true; c.rx_energy_ok = false; c.hw_rx_timestamp = false; /* the RXWI TSF field is not parsed */ /* The MAC inserts the live 64-bit TSF into the beacon it auto-transmits; diff --git a/src/mt7612u/Mt7612uRadio.h b/src/mt7612u/Mt7612uRadio.h index 02ebbb48..1198670e 100644 --- a/src/mt7612u/Mt7612uRadio.h +++ b/src/mt7612u/Mt7612uRadio.h @@ -99,6 +99,7 @@ class Mt7612uRadio : public IRadio { uint64_t ReadTsf() override; devourer::TxStats GetTxStats() override; devourer::ChannelBusy GetChannelBusy() override; + uint32_t ArmChannelBusy(uint32_t window_us) override; bool SetAckResponder(const devourer::MacAddr &mac) override; bool StartBeacon(const uint8_t *beacon, size_t len, int interval_tu) override; bool UpdateBeaconPayload(const uint8_t *beacon, size_t len) override; @@ -148,6 +149,11 @@ class Mt7612uRadio : public IRadio { std::mutex _teardown_mu; std::atomic _rx_stop{false}; std::atomic _rx_active{false}; + /* An armed busy window (IRadio::ArmChannelBusy) and whether a retune + * invalidated it. Guarded by _mu like every other register-adjacent member; + * the atomic above is separate because Stop() reads it without the lock. */ + bool _busy_armed = false; + bool _busy_spoiled = false; std::atomic _rx_frames{0}; /* Frames cross from the C library's event thread to the StartRxLoop thread diff --git a/src/mt7612u/include/mt7612u/mt7612u.h b/src/mt7612u/include/mt7612u/mt7612u.h index e3bacb86..4efa28a0 100644 --- a/src/mt7612u/include/mt7612u/mt7612u.h +++ b/src/mt7612u/include/mt7612u/mt7612u.h @@ -399,6 +399,14 @@ struct mt7612u_link_stats { */ int mt7612u_link_stats_start(struct mt7612u_dev *dev); +/* Arm the channel timers alone and restart their interval mark, for a caller + * measuring one busy window (IRadio::ArmChannelBusy). Separate from + * mt7612u_link_stats_start() on purpose: that one also clears the MIB block + * and the link-stats interval, which belong to the 1 Hz telemetry caller, and + * arming per dwell through it would corrupt every rate the tick reports. + * Returns 0 on success. */ +int mt7612u_ch_time_arm(struct mt7612u_dev *dev); + /* Read and clear. Returns 0 on success; fills the interval since the previous * call to this function or to _start(). */ int mt7612u_link_stats(struct mt7612u_dev *dev, struct mt7612u_link_stats *out); diff --git a/src/mt7612u/init.cpp b/src/mt7612u/init.cpp index ff4d55f6..6dc9479d 100644 --- a/src/mt7612u/init.cpp +++ b/src/mt7612u/init.cpp @@ -601,11 +601,43 @@ int mt7612u_link_stats_start(struct mt7612u_dev *d) FIELD_PREP(MT_CH_TIME_CFG_CH_TIMER_CLR, 1)); /* One read to clear everything, so the first real sample is clean. */ mt7612u_link_stats(d, &discard); - /* Arming is what makes a ch_time reading meaningful. Reset the mark with - * it: the next interval must start here, not at whatever the previous - * session left behind. */ + /* Arming is what makes a ch_time reading meaningful, so the interval + * STARTS here — not at whatever the previous session left behind, and not + * at "unknown". Stamping now rather than zeroing is what lets the first + * read after an arm report its own denominator: with a zero mark it came + * back window_us=0, and a busy percentage whose window is unknown is not + * a measurement a ranker can compare across channels. */ d->ch_time_armed = 1; - d->ch_time_last_us = 0; + d->ch_time_last_us = stats_now_us(); + return 0; +} + +/* Re-arm the CHANNEL TIMERS only, and restart their interval mark. + * + * Deliberately not mt7612u_link_stats_start(): that one also read-and-clears + * the whole MIB block and resets the link-stats interval, which belongs to the + * 1 Hz telemetry caller. Arming a busy window per survey dwell through that + * path would reset the tick's interval several times a second and corrupt + * every rate it reports. The channel timers have their own clear bit and their + * own mark, so the two callers need not collide. */ +int mt7612u_ch_time_arm(struct mt7612u_dev *d) +{ + uint32_t discard; + + if (!d) return -1; + /* Same configuration mt76's mt76x02_mac_cc_reset() uses, with the + * timer-clear field set so the counters restart from zero here. */ + mt_wr(d, MT_CH_TIME_CFG, + MT_CH_TIME_CFG_TIMER_EN | MT_CH_TIME_CFG_TX_AS_BUSY | + MT_CH_TIME_CFG_RX_AS_BUSY | MT_CH_TIME_CFG_NAV_AS_BUSY | + MT_CH_TIME_CFG_EIFS_AS_BUSY | MT_CH_CCA_RC_EN | + FIELD_PREP(MT_CH_TIME_CFG_CH_TIMER_CLR, 1)); + /* The pair is read-and-clear, so one throwaway read is the barrier + * between whatever accumulated before and the window opening now. */ + if (mt_rr_chk(d, MT_CH_BUSY, &discard) || mt_rr_chk(d, MT_CH_IDLE, &discard)) + return -1; + d->ch_time_armed = 1; + d->ch_time_last_us = stats_now_us(); return 0; } diff --git a/tests/busy_window_probe.cpp b/tests/busy_window_probe.cpp new file mode 100644 index 00000000..22ca9921 --- /dev/null +++ b/tests/busy_window_probe.cpp @@ -0,0 +1,341 @@ +/* busy_window_probe — the on-air half of IRadio::ArmChannelBusy. + * + * Drives the armed window against whatever the bench is transmitting and + * prints one machine-readable line per sample, so tests/busy_window_probe.sh + * can assert the separations the feature claims. It uses only the public + * contract (ArmChannelBusy + GetChannelBusy), which is the point: if a + * consumer cannot reproduce these numbers through that pair, the contract is + * wrong. + * + * Arms (--mode): + * sampled GetChannelBusy() with no window armed — the shipped ~2 ms sample, + * for the spread comparison. + * window arm, wait, read. The feature. + * interrupt arm, take an NHM read mid-window, read. Must come back INVALID + * with spoil=interrupted: the reading is destroyed on the JGR3 map + * and 3-4 points high on the 11AC map, and neither is this window. + * retune arm, retune mid-window, read. Must be INVALID (spoil=retuned): + * the counter runs across the channel change. + * early arm, read immediately. Must be INVALID (spoil=not-elapsed) + * rather than the previous window's latched value. + * txsess arm, TRANSMIT inside the window, read. Must stay valid and be + * flagged own_tx: Realtek reads low (the receiver is deaf while + * the PA is up), MediaTek reads high (it counts own airtime). + * stale arm, let it COMPLETE, read it, arm again, read immediately. The + * second read must be INVALID. The result register latches the + * previous window, so this only holds if the trigger clears the + * ready bit - an assumption the headless selftest can only model, + * which is why it is checked here against real silicon. + * race a second thread hammers GetRxQuality() for the whole window while + * this one arms and reads. Every reading must be either refused as + * interrupted or a full-length window — never a short one wearing a + * valid flag. This is the on-air check of the locking: without it + * the note could land before a concurrent arm while the re-arm + * lands after it, and a destroyed window reads back as data. + * quality like `interrupt`, but through GetRxQuality() instead of + * GetRxEnergy(). That is the call a consumer actually makes, and + * it is how a survey dwell springs the trap without ever touching + * the busy API. + * + * Exit 0 = ran, 3 = no adapter, 5 = this backend cannot arm a window. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if __has_include() +#include +#else +#include +#endif + +#include "AdapterCaps.h" +#include "DeviceSession.h" +#include "IRtlRadio.h" +#include "RadiotapBuilder.h" +#include "RxPacket.h" +#include "TxMode.h" +#include "WiFiDriver.h" +#include "logger.h" + +namespace { + +void nap(uint32_t ms) { + std::this_thread::sleep_for(std::chrono::milliseconds(ms)); +} + +const char *spoil_name(devourer::BusySpoil s) { + switch (s) { + case devourer::BusySpoil::Interrupted: + return "interrupted"; + case devourer::BusySpoil::Retuned: + return "retuned"; + case devourer::BusySpoil::NotElapsed: + return "not-elapsed"; + default: + return "none"; + } +} + +const char *source_name(devourer::BusySource s) { + switch (s) { + case devourer::BusySource::Clm: + return "clm"; + case devourer::BusySource::ChTime: + return "chtime"; + default: + return "none"; + } +} + +void emit(const char *mode, int i, const devourer::ChannelBusy &b, + uint32_t armed_us, uint32_t sent) { + /* No padding inside a key=value token: the harness parses these fields, and + * "busy= 71%" splits into two. */ + std::printf("BUSY mode=%-9s i=%2d valid=%d busy=%u%% src=%-6s window_us=%u " + "armed_us=%u spoil=%-11s own_tx=%d tx_frames=%u sent=%u\n", + mode, i, b.valid ? 1 : 0, b.busy_pct, source_name(b.source), + b.window_us, armed_us, spoil_name(b.spoil), + b.own_tx_in_window ? 1 : 0, b.own_tx_frames, sent); + std::fflush(stdout); +} + +/* Every exit from main() after the RX thread is running must go through this. + * That thread is detached and may still be inside Init/StartRxLoop, so letting + * DeviceSession's destructor close the handle under it is a use-after-free — + * which is exactly what the SKIP returns used to do. _exit runs no + * destructors, so the USB lock is dropped by hand first: one left behind makes + * the next run of this script refuse the adapter it just used. */ +struct Cleanup { + IRadio *dev = nullptr; + std::shared_ptr *lock = nullptr; + devourer::DeviceSession *session = nullptr; + bool rx_on = false; +}; +Cleanup g_cleanup; + +int finish(int code) { + std::fflush(stdout); + if (!g_cleanup.rx_on) + return code; + if (g_cleanup.dev) + g_cleanup.dev->StopRxLoop(); + if (g_cleanup.lock) + g_cleanup.lock->reset(); + if (g_cleanup.session) + g_cleanup.session->release_lock(); + _exit(code); +} + +} // namespace + +int main(int argc, char **argv) { + uint16_t vid = 0x0bda, pid = 0x8812; + int channel = 165, other = 100, reps = 8, window_ms = 240, rx_on = 0; + std::string mode = "window"; + for (int i = 1; i < argc; i++) { + if (!std::strcmp(argv[i], "--vid") && i + 1 < argc) + vid = (uint16_t)std::strtoul(argv[++i], nullptr, 0); + else if (!std::strcmp(argv[i], "--pid") && i + 1 < argc) + pid = (uint16_t)std::strtoul(argv[++i], nullptr, 0); + else if (!std::strcmp(argv[i], "--channel") && i + 1 < argc) + channel = std::atoi(argv[++i]); + else if (!std::strcmp(argv[i], "--other") && i + 1 < argc) + other = std::atoi(argv[++i]); + else if (!std::strcmp(argv[i], "--reps") && i + 1 < argc) + reps = std::atoi(argv[++i]); + else if (!std::strcmp(argv[i], "--window-ms") && i + 1 < argc) + window_ms = std::atoi(argv[++i]); + else if (!std::strcmp(argv[i], "--rx")) + rx_on = 1; + else if (!std::strcmp(argv[i], "--mode") && i + 1 < argc) + mode = argv[++i]; + else { + std::fprintf(stderr, + "usage: %s [--vid N --pid N] [--channel N] [--other N] " + "[--reps N] [--window-ms N] [--rx] " + "[--mode sampled|window|interrupt|retune|early|txsess]\n", + argv[0]); + return 2; + } + } + + auto logger = std::make_shared(); + libusb_context *ctx = nullptr; + if (libusb_init(&ctx) < 0) { + std::fprintf(stderr, "libusb_init failed\n"); + return 3; + } + devourer::DeviceSession session(logger); + libusb_device_handle *handle = libusb_open_device_with_vid_pid(ctx, vid, pid); + if (!handle) { + std::fprintf(stderr, "no adapter %04x:%04x\n", vid, pid); + return 3; + } + std::shared_ptr lock; + if (devourer::claim_interface_then_reset( + handle, devourer::find_wifi_interface(handle), logger, + /*do_reset=*/true, lock) != 0) { + session.adopt_handle(handle); + return 3; + } + session.adopt_handle(handle); + session.adopt_lock(lock); + + devourer::DeviceConfig cfg; + WiFiDriver driver(logger); + std::unique_ptr owned = driver.CreateRadio(handle, ctx, lock, cfg); + if (!owned) { + std::fprintf(stderr, "CreateRadio failed (chip support not built?)\n"); + return 3; + } + session.adopt_device(std::move(owned)); + IRadio *const dev = session.device(); + auto *const rtl = dynamic_cast(dev); + + const devourer::AdapterCaps caps = dev->GetAdapterCaps(); + std::printf("BUSY-GEN %s chip=%s busy_airtime_ok=%d busy_airtime_measured=%d " + "rx_energy_ok=%d\n", + devourer::generation_name(caps.generation), caps.chip_name, + caps.busy_airtime_ok ? 1 : 0, caps.busy_airtime_measured ? 1 : 0, + caps.rx_energy_ok ? 1 : 0); + std::fflush(stdout); + + const SelectedChannel chan_def{.Channel = static_cast(channel), + .ChannelOffset = 0, + .ChannelWidth = CHANNEL_WIDTH_20}; + std::thread rx_thread; + if (rx_on) { + /* The MediaTek MAC only accumulates busy time with the receiver running, + * so its arm is only meaningful under --rx. Init does not return on that + * backend (it drives the RX path inline), so it gets its own thread and + * the measurement runs on this one — the radio serialises register access + * internally. */ + rx_thread = std::thread([dev, chan_def]() { + dev->Init([](const Packet &) {}, chan_def); + }); + /* Detached immediately: Init does not return on that backend, so joining + * it would hang and letting the std::thread destructor see it joinable + * would abort the process. */ + rx_thread.detach(); + g_cleanup.dev = dev; + g_cleanup.lock = &lock; + g_cleanup.session = &session; + g_cleanup.rx_on = true; + /* Bring-up finishes asynchronously, and the arm refuses until the + * receiver is actually running, so wait for it rather than guessing. */ + /* Bring-up finishes asynchronously and the arm refuses until the receiver + * is actually running, so wait for it rather than guessing. Note the + * MT7612U does not always get there while the channel is already + * saturated — bring it up before the interferer, not after. */ + for (int t = 0; t < 60; t++) { + nap(250); + if (dev->ArmChannelBusy(1000) != 0) { + /* Consume it: an armed window left behind would make the first + * `sampled` read take the ARMED branch and report a 1 ms window as + * the shipped sampled path. */ + nap(5); + (void)dev->GetChannelBusy(); + break; + } + } + } else { + dev->InitWrite(chan_def); + nap(200); + } + + const uint32_t window_us = static_cast(window_ms) * 1000u; + + for (int i = 1; i <= reps; i++) { + uint32_t sent = 0; + + if (mode == "sampled") { + const devourer::ChannelBusy b = dev->GetChannelBusy(); + emit("sampled", i, b, 0, 0); + nap(300); /* a survey dwell cadence */ + continue; + } + + const uint32_t armed = dev->ArmChannelBusy(window_us); + if (armed == 0) { + std::printf("SKIP backend cannot arm a busy window\n"); + return finish(5); + } + /* Wait the window the hardware actually granted, not the one requested. */ + const uint32_t wait_ms = armed / 1000u + 15u; + + if (mode == "interrupt") { + nap(wait_ms / 2); + if (rtl) + (void)rtl->GetRxEnergy(/*with_nhm=*/true); + nap(wait_ms / 2); + } else if (mode == "retune") { + nap(wait_ms / 2); + dev->SetMonitorChannel( + SelectedChannel{.Channel = static_cast(other), + .ChannelOffset = 0, + .ChannelWidth = CHANNEL_WIDTH_20}); + nap(wait_ms / 2); + } else if (mode == "race") { + std::atomic stop{false}; + std::thread hammer([&]() { + while (!stop.load(std::memory_order_relaxed)) { + if (rtl) + (void)rtl->GetRxQuality(); + } + }); + nap(wait_ms); + stop.store(true, std::memory_order_relaxed); + hammer.join(); + } else if (mode == "quality") { + nap(wait_ms / 2); + if (rtl) + (void)rtl->GetRxQuality(); + nap(wait_ms / 2); + } else if (mode == "early") { + nap(5); + } else if (mode == "stale") { + /* Let this window finish and consume it, so the result register holds a + * completed measurement; then arm again and read at once. */ + nap(wait_ms); + const devourer::ChannelBusy done = dev->GetChannelBusy(); + emit("stale-1st", i, done, armed, 0); + if (dev->ArmChannelBusy(window_us) == 0) { + std::printf("SKIP second arm refused\n"); + return finish(5); + } + nap(5); + } else if (mode == "txsess") { + static const uint8_t dot11[36] = {0x08, 0x00, 0x00, 0x00, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0x02, 0x11, + 0x22, 0x33, 0x44, 0x55, 0x02, 0x11, + 0x22, 0x33, 0x44, 0x55}; + devourer::TxMode tx_mode; + const std::vector rt = devourer::build_stream_radiotap(tx_mode); + std::vector buf(rt.begin(), rt.end()); + buf.insert(buf.end(), dot11, dot11 + sizeof dot11); + const auto t0 = std::chrono::steady_clock::now(); + while (std::chrono::steady_clock::now() - t0 < + std::chrono::milliseconds(wait_ms)) { + if (dev->send_packet(buf.data(), buf.size())) + sent++; + } + } else { + nap(wait_ms); + } + + const devourer::ChannelBusy b = dev->GetChannelBusy(); + emit(mode.c_str(), i, b, armed, sent); + + if (mode == "retune") + dev->SetMonitorChannel(chan_def); + } + return finish(0); +} diff --git a/tests/busy_window_probe.sh b/tests/busy_window_probe.sh new file mode 100755 index 00000000..d6bb280c --- /dev/null +++ b/tests/busy_window_probe.sh @@ -0,0 +1,287 @@ +#!/usr/bin/env bash +# Does an ARMED busy window measure the caller's dwell, where the sampled read +# only samples it — and does a spoiled window refuse instead of lying? +# +# Two adapters on one channel: a devourer flooder and a sensor. The flooder is +# the reference, so the same load can be put to both silicon families and the +# answers compared; with DUTY_ON/DUTY_OFF it becomes a bursty interferer, which +# is where the sampled path falls apart (one ~2 ms read per dwell measured ZERO +# in 55 of 71 windows on an RTL8822BU while 300-400 frames per window were +# decoded). +# +# Arms, in order: +# quiet sensor alone -- the floor. Must read ~0, not "no reading". +# window armed, under load -- the feature. +# sampled unarmed, same load -- the spread comparison. +# interrupt NHM read mid-window -- must be INVALID, spoil=interrupted. +# retune retune mid-window -- must be INVALID, spoil=retuned. +# early read before elapsed -- must be INVALID, spoil=not-elapsed. +# txsess sensor transmits -- must stay VALID and be flagged own_tx. +# +# sudo tests/busy_window_probe.sh +# SENSOR_PID=0xc812 FLOOD_PID=0x8812 CHANNEL=100 sudo tests/busy_window_probe.sh +# DUTY_ON=50 DUTY_OFF=450 sudo tests/busy_window_probe.sh # bursty arm +# +# A MediaTek sensor needs SENSOR_RX=1 (its MAC only counts busy with the +# receiver running) and must be brought up BEFORE the flooder: its bring-up +# does not reliably complete on an already-saturated channel. +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PROBE="$ROOT/build/BusyWindowProbe" +TXDEMO="$ROOT/build/txdemo" + +SENSOR_VID="${SENSOR_VID:-0x0bda}" +SENSOR_PID="${SENSOR_PID:-0x8812}" # 8812AU (Jaguar1) +FLOOD_VID="${FLOOD_VID:-0x0e8d}" +FLOOD_PID="${FLOOD_PID:-0x7612}" # MT7612U +CHANNEL="${CHANNEL:-165}" +OTHER="${OTHER:-100}" +REPS="${REPS:-5}" +WINDOW_MS="${WINDOW_MS:-240}" +SENSOR_RX="${SENSOR_RX:-0}" +DUTY_ON="${DUTY_ON:-0}" +DUTY_OFF="${DUTY_OFF:-0}" +OUT="${OUT:-/tmp/devourer-busy-window}" + +plugged() { lsusb -d "$(printf '%04x:%04x' "$1" "$2")" >/dev/null 2>&1; } +plugged "$SENSOR_VID" "$SENSOR_PID" || { + echo "SKIP: sensor $SENSOR_VID:$SENSOR_PID not plugged"; exit 77; } +plugged "$FLOOD_VID" "$FLOOD_PID" || { + echo "SKIP: flooder $FLOOD_VID:$FLOOD_PID not plugged"; exit 77; } + +mkdir -p "$OUT" +rx_flag=""; [ "$SENSOR_RX" = "1" ] && rx_flag="--rx" +fails=0 +skips=0 + +stop_flood() { pkill -x txdemo 2>/dev/null; sleep 1; } +trap 'stop_flood' EXIT INT TERM + +start_flood() { + stop_flood + local burst=() + if [ "$DUTY_ON" != "0" ]; then + burst=(DEVOURER_TX_BURST_ON_MS="$DUTY_ON" DEVOURER_TX_BURST_OFF_MS="$DUTY_OFF") + fi + env DEVOURER_VID="$FLOOD_VID" DEVOURER_PID="$FLOOD_PID" \ + DEVOURER_CHANNEL="$CHANNEL" DEVOURER_TX_RATE=MCS1 DEVOURER_TX_GAP_US=0 \ + "${burst[@]}" "$TXDEMO" > "$OUT/flood.log" 2>&1 & + sleep 4 + grep -qE "tx\.(frame|stats)|bulk_send" "$OUT/flood.log" || { + echo "WARN: flooder produced no frames — every 'under load' arm below is" + echo " really a quiet-channel run and proves nothing." + fails=$((fails + 1)) + } +} + +flood_frames() { # how many frames the flooder has emitted so far + local n + n=$(grep -cE "tx\.(frame|stats)|bulk_send" "$OUT/flood.log" 2>/dev/null) + echo "${n:-0}" # see expect_all: grep -c exits 1 on no match +} + +# A per-arm liveness check. The one-shot grep at start_flood time cannot tell +# that the flooder DIED before the arm that needed it, and a dead flooder turns +# every "under load" assertion into a quiet-channel run that passes by +# accident. +assert_flood_alive() { # label + local before="$1" label="$2" after + after="$(flood_frames)" + if [ "$after" -le "$before" ]; then + echo "FAIL $label: flooder emitted nothing during this arm" + fails=$((fails + 1)) + fi +} + +busy_values() { # mode -> one busy percentage per valid sample + grep -E "^BUSY mode" "$OUT/$1.log" 2>/dev/null | grep "valid=1" \ + | sed -E 's/.*busy=([0-9]+)%.*/\1/' +} + +stat_of() { # mode, mean|min|max|spread + busy_values "$1" | awk -v want="$2" ' + { n++; s += $1; if (n == 1 || $1 < lo) lo = $1; if (n == 1 || $1 > hi) hi = $1 } + END { if (!n) { print "nan"; exit } + if (want == "mean") printf "%.0f\n", s / n; + else if (want == "min") print lo; + else if (want == "max") print hi; + else print hi - lo }' +} + +assert_le() { # label, value, bound + [ "$2" = "nan" ] && { echo "FAIL $1: no samples"; fails=$((fails + 1)); return; } + if [ "$2" -le "$3" ]; then echo " ok: $1 ($2 <= $3)"; + else echo "FAIL $1: $2 > $3"; fails=$((fails + 1)); fi +} + +assert_ge() { # label, value, bound + [ "$2" = "nan" ] && { echo "FAIL $1: no samples"; fails=$((fails + 1)); return; } + if [ "$2" -ge "$3" ]; then echo " ok: $1 ($2 >= $3)"; + else echo "FAIL $1: $2 < $3"; fails=$((fails + 1)); fi +} + +run_arm() { # mode, label + local mode="$1" label="$2" + echo "== $label" + timeout 120 "$PROBE" --vid "$SENSOR_VID" --pid "$SENSOR_PID" \ + --channel "$CHANNEL" --other "$OTHER" --reps "$REPS" \ + --window-ms "$WINDOW_MS" --mode "$mode" $rx_flag 2>/dev/null \ + | tee "$OUT/$mode.log" | grep -E "^BUSY" +} + +# A spoiled arm must produce NO valid readings; a working arm must produce +# only valid ones. Both directions matter: a probe that passes whatever the +# hardware does is not a test. +expect_all() { # mode, valid(0|1), [spoil] + local mode="$1" want_valid="$2" want_spoil="${3:-}" + local n bad + # `grep -c` prints 0 and EXITS 1 when it matches nothing, so a `|| echo 0` + # here appended a second line and made this guard dead: an empty log then + # reported "ok: 0 samples". The whole point of the guard is the empty case. + n=$(grep -cE "^BUSY mode" "$OUT/$mode.log" 2>/dev/null); n=${n:-0} + if [ "$n" -eq 0 ]; then + echo "FAIL $mode: no samples"; fails=$((fails + 1)); return + fi + bad=$(grep -E "^BUSY mode" "$OUT/$mode.log" | grep -vc "valid=$want_valid") + if [ "$bad" -ne 0 ]; then + echo "FAIL $mode: $bad/$n samples not valid=$want_valid" + fails=$((fails + 1)) + fi + if [ -n "$want_spoil" ]; then + bad=$(grep -E "^BUSY mode" "$OUT/$mode.log" | grep -vc "spoil=$want_spoil") + [ "$bad" -eq 0 ] || { + echo "FAIL $mode: $bad/$n samples not spoil=$want_spoil" + fails=$((fails + 1)); } + fi + echo " ok: $n samples valid=$want_valid ${want_spoil:+spoil=$want_spoil}" +} + +echo "== sensor $SENSOR_VID:$SENSOR_PID, flooder $FLOOD_VID:$FLOOD_PID, ch$CHANNEL" + +# The Realtek-only arms drive IRtlRadio facilities (the NHM read, the CCX +# result latch). A MediaTek sensor runs the rest. +realtek_sensor=1 +[ "$SENSOR_VID" = "0x0e8d" ] && realtek_sensor=0 + +stop_flood +run_arm window "quiet floor (no flooder)" +mv -f "$OUT/window.log" "$OUT/quiet.log" +expect_all quiet 1 # a quiet channel is a READING of ~0, never "no reading" +quiet_mean="$(stat_of quiet mean)" +# The floor must BE a floor. Without this the whole comparison below passes +# with the sensor parked on the wrong channel. +assert_le "quiet floor is quiet" "$quiet_mean" 5 + +# A MediaTek sensor cannot be brought up on an already-saturated channel: its +# firmware MCU times out and the arm never becomes available (measured). The +# loaded arms therefore need the flooder started AFTER the sensor is up, which +# is one process order this script cannot express — each arm launches its own +# probe. Skipped explicitly rather than run into a failure that says nothing +# about the code, and never counted as a pass. +if [ "$realtek_sensor" != "1" ]; then + echo "== SKIPPING the loaded arms: a MediaTek sensor must be brought up" + echo " BEFORE the interferer. Run those arms by hand:" + echo " build/BusyWindowProbe --vid $SENSOR_VID --pid $SENSOR_PID \\" + echo " --channel $CHANNEL --mode window --reps 45 --rx &" + echo " # then start the flooder once it prints its first sample" + skips=$((skips + 1)) + echo + [ "$fails" -eq 0 ] && echo "busy_window_probe: PASS (quiet arms only, $skips skipped)" \ + || echo "busy_window_probe: $fails FAILURE(S)" + exit $((fails ? 1 : 0)) +fi + +start_flood +f0="$(flood_frames)" +run_arm window "armed window under load" +expect_all window 1 +assert_flood_alive "$f0" "window" +window_mean="$(stat_of window mean)" +# The measurement must MOVE with the load, by much more than the floor's own +# variation. An implementation returning a plausible constant fails here. +# +# The bar depends on the arm, because the load does: a saturating flooder puts +# the channel above 60%, while DUTY_ON=50/DUTY_OFF=450 is ~9% occupancy by +# construction and a 15-point bar would be asserting something untrue. +sep_min=15 +[ "$DUTY_ON" != "0" ] && sep_min=5 +assert_ge "load separates from floor" "$((window_mean - quiet_mean))" "$sep_min" + +f0="$(flood_frames)" +run_arm sampled "shipped sampled read, same load" +expect_all sampled 1 +assert_flood_alive "$f0" "sampled" + +f0="$(flood_frames)" +if [ "$realtek_sensor" = "1" ]; then + run_arm interrupt "NHM read mid-window" + expect_all interrupt 0 interrupted + run_arm quality "GetRxQuality() mid-window — the trap a consumer springs" + expect_all quality 0 interrupted + # The locking, on air: a second thread hammering GetRxQuality for the whole + # window must never yield a SHORT window wearing a valid flag. Without the + # CCX lock the note can land before a concurrent arm while the re-arm lands + # after it, and a destroyed window reads back as data. + run_arm race "GetRxQuality() hammered from another thread" + expect_all race 0 interrupted +else + echo "== NHM arms skipped (non-Realtek sensor has no NHM engine)" +fi + +run_arm retune "retune mid-window" +expect_all retune 0 retuned + +if [ "$realtek_sensor" = "1" ]; then + # Realtek-only: the not-elapsed refusal exists because the CCX result + # register latches the previous window. The MediaTek timers have no such + # concept — a short arm there is simply a short, and valid, window. + run_arm early "read before the window elapsed" + expect_all early 0 not-elapsed + run_arm stale "re-arm, then read before the new window elapsed" + # The first read of each pair must be a real measurement and the second must + # refuse. If the trigger did NOT clear the ready bit, the second read would + # return the first window's latched value and look perfectly valid. + grep -E "^BUSY mode=stale-1st" "$OUT/stale.log" | grep -q "valid=1" || { + echo "FAIL stale: the completed window did not read back"; fails=$((fails + 1)); } + bad=$(grep -E "^BUSY mode=stale " "$OUT/stale.log" | grep -vc "valid=0") + if [ "${bad:-1}" -eq 0 ]; then + echo " ok: a re-armed window never returns the previous latched result" + else + echo "FAIL stale: $bad early reads returned a stale latched value" + fails=$((fails + 1)) + fi +fi + +f0="$(flood_frames)" +run_arm txsess "sensor transmitting inside its own window" +expect_all txsess 1 +assert_flood_alive "$f0" "txsess" +grep -E "^BUSY mode" "$OUT/txsess.log" | grep -q "own_tx=1" || { + echo "FAIL txsess: own transmission not flagged"; fails=$((fails + 1)); } +stop_flood + +echo +echo "== spread: the point of the window" +for m in window sampled; do + printf " %-8s n=%s mean=%s%% min=%s%% max=%s%% spread=%s pts\n" \ + "$m" "$(busy_values $m | wc -l)" "$(stat_of $m mean)" "$(stat_of $m min)" \ + "$(stat_of $m max)" "$(stat_of $m spread)" +done +# The claim this feature exists for, gated rather than printed. Under a STEADY +# load both are tight, so this only means something with DUTY_ON set — hence +# the guard rather than an unconditional assert. +if [ "$DUTY_ON" != "0" ]; then + w_spread="$(stat_of window spread)"; s_spread="$(stat_of sampled spread)" + if [ "$w_spread" != "nan" ] && [ "$s_spread" != "nan" ]; then + if [ "$w_spread" -lt "$s_spread" ]; then + echo " ok: armed spread $w_spread < sampled spread $s_spread (bursty load)" + else + echo "FAIL bursty: armed spread $w_spread not below sampled $s_spread" + fails=$((fails + 1)) + fi + fi +fi + +echo +[ "$fails" -eq 0 ] && echo "busy_window_probe: PASS" || echo "busy_window_probe: $fails FAILURE(S)" +exit $((fails ? 1 : 0)) diff --git a/tests/busy_window_selftest.cpp b/tests/busy_window_selftest.cpp new file mode 100644 index 00000000..8a679ba7 --- /dev/null +++ b/tests/busy_window_selftest.cpp @@ -0,0 +1,389 @@ +/* Headless guard for the CLM busy-window state machine (src/BusyWindow.h). + * + * The register access is mocked, so every path the hardware taught us is + * reachable without a radio: the clamp, the arm/read register sequence, the + * period-bounded ratio, and — the load-bearing half — the three ways a window + * stops describing the caller's dwell. Each of those must yield NO READING. + * A spoiled window that returns a number is worse than one that returns + * nothing: on the 11AC map the corruption is a 3-4 point overcount, which no + * consumer can distinguish from a real channel. + */ +#include "BusyWindow.h" + +#include +#include +#include +#include + +using devourer::BusySource; +using devourer::BusySpoil; +using devourer::ChannelBusy; +using devourer::ClmWindow; +using devourer::kClmMaxPeriodTicks; +using devourer::kClmMinPeriodTicks; +using devourer::NhmRegs; +using devourer::nhm_regs_11ac; + +static int g_fail = 0; + +static void check(const char *what, long got, long want) { + if (got != want) { + std::printf("FAIL %s: got %ld want %ld\n", what, got, want); + ++g_fail; + } +} + +namespace { + +/* A baseband stand-in that applies the masked-write semantics the real + * PHY_SetBBReg8812 has (value shifted by the mask's own bit position), so a + * pre-shifted write shows up here as the wrong field, exactly as it would on + * silicon. */ +struct Write { + uint16_t addr; + uint32_t mask; + uint32_t value; /* as passed, before the mask shift */ +}; + +struct MockBb { + std::map regs; + std::vector writes; + /* What the "hardware" will report for the CLM result register. */ + uint16_t clm_ticks = 0; + bool clm_ready = true; + /* The register map under test, so the JGR3 addresses can be exercised too. */ + NhmRegs map = nhm_regs_11ac(); + + uint32_t read32(uint16_t addr) { + if (addr == map.clm) + return (clm_ready ? (1u << 16) : 0u) | clm_ticks; + return regs[addr]; + } + void set_bb(uint16_t addr, uint32_t mask, uint32_t value) { + int shift = 0; + while (shift < 32 && !((mask >> shift) & 1u)) + shift++; + regs[addr] = (regs[addr] & ~mask) | ((value << shift) & mask); + writes.push_back(Write{addr, mask, value}); + /* Model the hardware the refusals depend on: triggering CLM starts a new + * window, so the ready bit drops until it completes. A build where the + * trigger did NOT clear ready would return the previous window's latched + * value, which is what the NotElapsed refusal exists to prevent - the + * on-air `stale` arm in tests/busy_window_probe.sh is what checks that + * this model matches the silicon. */ + if (addr == map.ctrl && (mask & 0x1u) && value == 1) + clm_ready = false; + } + /* Did anything pulse the NHM trigger (ctrl bit1) to 1? A rising edge there + * re-arms the shared engine, which is exactly what a CLM-only arm must not + * do - and a check on the FINAL register image cannot see a 0->1->0 pulse. */ + bool nhm_trigger_pulsed() const { + for (const Write &w : writes) + if (w.addr == map.ctrl && (w.mask & 0x2u) && (w.value & 0x1u)) + return true; + return false; + } + ClmWindow::Read32 rd() { + return [this](uint16_t a) { return read32(a); }; + } + ClmWindow::SetBb wr() { + return [this](uint16_t a, uint32_t m, uint32_t v) { set_bb(a, m, v); }; + } +}; + +} // namespace + +int main() { + const NhmRegs regs = nhm_regs_11ac(); + + /* --- the clamp: a window nobody can program must not be pretended --- */ + { + MockBb bb; + ClmWindow w; + const uint32_t armed = w.arm(regs, 10u * 1000u * 1000u, 0, bb.wr()); + check("clamp: armed window is the ceiling", + armed, static_cast(kClmMaxPeriodTicks) * 4); + const uint32_t period = bb.regs[regs.period] & 0xffffu; + check("clamp: period register holds the ceiling", period, + kClmMaxPeriodTicks); + } + { + MockBb bb; + ClmWindow w; + const uint32_t armed = w.arm(regs, 100, 0, bb.wr()); + check("clamp: a sub-millisecond request is floored", + armed, static_cast(kClmMinPeriodTicks) * 4); + } + /* Zero is a caller bug rather than "the minimum": nothing armed, no + * registers touched, and a later read must not invent a window. */ + { + MockBb bb; + ClmWindow w; + const uint32_t armed = w.arm(regs, 0, 0, bb.wr()); + check("zero window: refused", armed, 0); + check("zero window: nothing armed", w.armed() ? 1 : 0, 0); + check("zero window: no register writes", + static_cast(bb.writes.size()), 0); + bb.clm_ticks = 30000; + const ChannelBusy b = w.read(regs, 0, bb.rd()); + check("zero window: no reading", b.valid, 0); + } + + /* --- the arm sequence: CLM only, NHM's half of the dword untouched --- */ + { + MockBb bb; + /* Pre-load NHM's half of the period dword; arming CLM must not move it. + * Start from the state a previous read_nhm leaves behind — both triggers + * high — so "left alone" is tested against a realistic prior state and + * not against a conveniently zeroed register. */ + bb.regs[regs.period] = 0x01f40000u; + bb.regs[regs.ctrl] = 0x3u; + ClmWindow w; + w.arm(regs, 240000, 0, bb.wr()); + check("arm: no NHM trigger edge", bb.nhm_trigger_pulsed() ? 1 : 0, 0); + check("arm: NHM period half preserved", + (bb.regs[regs.period] >> 16) & 0xffffu, 0x01f4); + check("arm: CLM period half programmed", + bb.regs[regs.period] & 0xffffu, 60000); + check("arm: ccx_en set", (bb.regs[regs.ctrl] >> 8) & 1u, 1); + check("arm: CLM trigger left high", bb.regs[regs.ctrl] & 1u, 1); + /* It was 1 before the arm and the arm must not have driven it either way: + * the final image still shows the NHM engine as the previous caller left + * it. Paired with the pulse check above, which a final-state check alone + * cannot make. */ + check("arm: NHM trigger left as found", (bb.regs[regs.ctrl] >> 1) & 1u, 1); + } + + /* --- a clean window reports the ratio against its own period --- */ + { + MockBb bb; + ClmWindow w; + /* 240 ms is the ceiling (kClmMaxPeriodTicks), so this is also the + * longest window a caller can actually get. */ + w.arm(regs, 240000, 0, bb.wr()); /* 60000 ticks */ + bb.clm_ticks = 36000; /* 60% */ + bb.clm_ready = true; /* the window completed */ + const ChannelBusy b = w.read(regs, 0, bb.rd()); + check("clean: valid", b.valid, 1); + check("clean: source is CLM", static_cast(b.source), + static_cast(BusySource::Clm)); + check("clean: busy pct", b.busy_pct, 60); + check("clean: window_us is the armed window", b.window_us, 240000); + check("clean: no own TX", b.own_tx_in_window, 0); + check("clean: spoil clear", static_cast(w.last_spoil()), + static_cast(BusySpoil::None)); + } + + /* A result at or above the period is 100%, never an overflow artefact. */ + { + MockBb bb; + ClmWindow w; + w.arm(regs, 240000, 0, bb.wr()); + bb.clm_ticks = 60000; + bb.clm_ready = true; + const ChannelBusy b = w.read(regs, 0, bb.rd()); + check("saturated: 100%", b.busy_pct, 100); + } + + /* --- an NHM read inside the window: JGR3 truncates it, 11AC inflates it, + * and neither is a reading about this window --- */ + { + MockBb bb; + ClmWindow w; + w.arm(regs, 240000, 0, bb.wr()); + w.note_nhm_read(); + bb.clm_ticks = 37500; + bb.clm_ready = true; + const ChannelBusy b = w.read(regs, 0, bb.rd()); + check("nhm-spoiled: no reading", b.valid, 0); + check("nhm-spoiled: busy invalid", b.valid_busy, 0); + check("nhm-spoiled: reason reported", static_cast(w.last_spoil()), + static_cast(BusySpoil::Interrupted)); + } + + /* --- a retune inside the window blends two channels --- */ + { + MockBb bb; + ClmWindow w; + w.arm(regs, 240000, 0, bb.wr()); + w.note_retune(); + bb.clm_ticks = 37500; + bb.clm_ready = true; + const ChannelBusy b = w.read(regs, 0, bb.rd()); + check("retune-spoiled: no reading", b.valid, 0); + check("retune-spoiled: reason reported", static_cast(w.last_spoil()), + static_cast(BusySpoil::Retuned)); + } + + /* --- an early read returns the PREVIOUS window on hardware, so the ready + * bit being clear must be a refusal, not a zero --- */ + { + MockBb bb; + ClmWindow w; + w.arm(regs, 240000, 0, bb.wr()); + bb.clm_ready = false; + bb.clm_ticks = 37500; + const ChannelBusy b = w.read(regs, 0, bb.rd()); + check("early: no reading", b.valid, 0); + check("early: reason reported", static_cast(w.last_spoil()), + static_cast(BusySpoil::NotElapsed)); + } + + /* --- reading without arming is not a quiet channel --- */ + { + MockBb bb; + ClmWindow w; + bb.clm_ticks = 37500; + const ChannelBusy b = w.read(regs, 0, bb.rd()); + check("unarmed: no reading", b.valid, 0); + } + + /* --- one arm, one reading: a second read must not re-report a window that + * has already been consumed, because the result register still holds it --- */ + { + MockBb bb; + ClmWindow w; + w.arm(regs, 240000, 0, bb.wr()); + bb.clm_ticks = 37500; + bb.clm_ready = true; + const ChannelBusy first = w.read(regs, 0, bb.rd()); + const ChannelBusy second = w.read(regs, 0, bb.rd()); + check("consumed: first valid", first.valid, 1); + check("consumed: second refuses", second.valid, 0); + } + + /* --- own transmission is carried, not corrected: the reading stays valid + * and says it was taken hot --- */ + { + MockBb bb; + ClmWindow w; + w.arm(regs, 240000, /*tx_submitted=*/1000, bb.wr()); + bb.clm_ticks = 12000; + bb.clm_ready = true; /* 20% — the depressed reading a hot sensor gives */ + const ChannelBusy b = w.read(regs, /*tx_submitted=*/1900, bb.rd()); + check("own-tx: still a reading", b.valid, 1); + check("own-tx: flagged", b.own_tx_in_window, 1); + check("own-tx: frame count", b.own_tx_frames, 900); + check("own-tx: value not silently corrected", b.busy_pct, 20); + } + + /* A TX counter that jumped forward by more than the 32-bit field can hold + * must SATURATE, not truncate: a truncated delta can land on a small number + * that reads as "barely transmitting". */ + { + MockBb bb; + ClmWindow w; + w.arm(regs, 240000, /*tx_submitted=*/0, bb.wr()); + bb.clm_ticks = 0; + bb.clm_ready = true; + const ChannelBusy b = + w.read(regs, /*tx_submitted=*/0x1'0000'0003ull, bb.rd()); + check("own-tx: huge delta saturates", b.own_tx_frames == 0xffffffffu, 1); + check("own-tx: huge delta still flagged", b.own_tx_in_window, 1); + } + + /* A TX counter that went backwards (a reset between arm and read) must not + * underflow into a huge frame count. */ + { + MockBb bb; + ClmWindow w; + w.arm(regs, 240000, /*tx_submitted=*/5000, bb.wr()); + bb.clm_ticks = 0; + bb.clm_ready = true; + const ChannelBusy b = w.read(regs, /*tx_submitted=*/10, bb.rd()); + check("own-tx: counter reset does not underflow", b.own_tx_frames, 0); + check("own-tx: counter reset leaves it unflagged", b.own_tx_in_window, 0); + } + + /* --- the stale latch: arm, complete, read, arm again, read early. The + * result register still holds the FIRST window's value, so a build that + * trusted it would report a completed measurement for a window that has + * barely started. This is the case the on-air `stale` arm re-checks against + * real silicon, because it depends on the trigger clearing ready. --- */ + { + MockBb bb; + ClmWindow w; + w.arm(regs, 240000, 0, bb.wr()); + bb.clm_ticks = 48000; /* 80% */ + bb.clm_ready = true; + const ChannelBusy first = w.read(regs, 0, bb.rd()); + check("stale: first window valid", first.valid, 1); + check("stale: first window value", first.busy_pct, 80); + w.arm(regs, 240000, 0, bb.wr()); /* the mock drops ready, as the chip does */ + const ChannelBusy second = w.read(regs, 0, bb.rd()); + check("stale: second read refuses", second.valid, 0); + check("stale: refusal is not-elapsed", static_cast(second.spoil), + static_cast(BusySpoil::NotElapsed)); + } + + /* --- arming twice without reading: the second arm owns the window, and the + * first one's spoilage must not leak into it --- */ + { + MockBb bb; + ClmWindow w; + w.arm(regs, 240000, 0, bb.wr()); + w.note_retune(); /* spoils the FIRST window */ + w.arm(regs, 240000, 0, bb.wr()); /* a new window starts clean */ + bb.clm_ticks = 30000; + bb.clm_ready = true; + const ChannelBusy b = w.read(regs, 0, bb.rd()); + check("re-arm: clears the previous spoil", b.valid, 1); + check("re-arm: reports the new window", b.busy_pct, 50); + } + + /* --- a spoil arriving after a clean read must not taint the next window, + * and a note with nothing armed is a no-op --- */ + { + MockBb bb; + ClmWindow w; + w.arm(regs, 240000, 0, bb.wr()); + bb.clm_ticks = 30000; + bb.clm_ready = true; + (void)w.read(regs, 0, bb.rd()); + w.note_nhm_read(); /* nothing armed: must be ignored */ + check("unarmed note: nothing armed", w.armed() ? 1 : 0, 0); + w.arm(regs, 240000, 0, bb.wr()); + bb.clm_ticks = 30000; + bb.clm_ready = true; + const ChannelBusy b = w.read(regs, 0, bb.rd()); + check("unarmed note: next window unaffected", b.valid, 1); + } + + /* --- spoil precedence: the FIRST reason is kept, because it is the one + * that describes what happened to the measurement --- */ + { + MockBb bb; + ClmWindow w; + w.arm(regs, 240000, 0, bb.wr()); + w.note_nhm_read(); + w.note_retune(); + bb.clm_ticks = 30000; + bb.clm_ready = true; + const ChannelBusy b = w.read(regs, 0, bb.rd()); + check("precedence: invalid", b.valid, 0); + check("precedence: first reason kept", static_cast(b.spoil), + static_cast(BusySpoil::Interrupted)); + } + + /* --- the JGR3 map: different addresses, same state machine. The map is + * data, so a mix-up (reading CLM from the 11AC address on a Jaguar3) is a + * silent wrong-register read, not a compile error. --- */ + { + const NhmRegs jgr3 = devourer::nhm_regs_jgr3(); + MockBb bb; + bb.map = jgr3; + ClmWindow w; + w.arm(jgr3, 240000, 0, bb.wr()); + check("jgr3: period programmed at its own address", + bb.regs[jgr3.period] & 0xffffu, 60000); + check("jgr3: 11AC period untouched", bb.regs[nhm_regs_11ac().period], 0); + bb.clm_ticks = 15000; + bb.clm_ready = true; + const ChannelBusy b = w.read(jgr3, 0, bb.rd()); + check("jgr3: reading", b.busy_pct, 25); + } + + if (g_fail == 0) + std::printf("busy_window_selftest: all checks passed\n"); + return g_fail ? 1 : 0; +} From c749eb765c1e0e9ab744647f56f400495ff3b2d1 Mon Sep 17 00:00:00 2001 From: snokvist Date: Sun, 20 Sep 2026 10:27:11 +0200 Subject: [PATCH 2/4] sensing: the review round on the busy window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qodo raised thirteen findings on #437 and three more review passes of my own found seven others. The ones that could hand a consumer a wrong number, all on the MediaTek path, which had an armed-window flag but none of the state a reading needs: - No deadline, so an early read returned a valid short window where the Realtek path refuses one. The requested length is recorded at arm and the C layer's interval mark measures against it. - The armed flag was cleared in one critical section and the timers read in another; a retune landing in the gap found nothing to spoil and the post-retune remainder came back valid. One locked section now, and _rx_active is re-checked inside it — the old check-then-lock let StopRxLoop finish in between, and the C layer answers regardless, which is the fabricated zero the gate exists to prevent. - own_tx_in_window/own_tx_frames were never populated there although these timers count own transmission as busy. A TX baseline is taken at arm. - An armed window survived an RX stop/start that re-zeroes the hardware timers under it, and a zero-length request returned 0 — the "cannot arm" answer — while arming anyway. The MediaTek decision moved out of the backend into a pure devourer::busy_from_ch_time_window() next to the conversion it qualifies, so those rules are now covered headlessly (the backend itself needs a radio): every refusal, the elapsed boundary both ways, own-TX including a counter that went backwards, and an armed window over dead counters. A self-deadlock of my own making: Jaguar1's FastRetune/FastSetBandwidth held busy_window_mutex() across the tune, and their declined fast paths call SetMonitorChannel(), which takes that same non-recursive mutex. One rule, two shapes now — where a family has a register lock that spans the change the note sits inside it and with_ccx's ordering does the rest; Jaguar1 has none, so the note is scoped and the single-control-thread contract carries it. Jaguar2's FastRetune also kept a leftover pre-lock note that took the CCX mutex BEFORE _reg_mu, the inversion with_ccx is ordered against. Deleted. Two claims of mine were simply false and are now either true or gone: - I described the disturbed-counter hazard as a 1 Hz telemetry poll stealing the shared timers. Nothing in Mt7612uRadio polls mt7612u_link_stats(); only a C-API caller that uses both does (tools/bringup.cpp). The guard stays, because such a caller really can take the counts, but it says so accurately — and it no longer breaks the public C signature: mt7612u_ch_time() keeps its four parameters and the flag has its own accessor, with both new symbols added to the api_link surface guard. - The saturation test claimed to cover the clamp while passing without it: at a tick count just over the period, rounding lands on 100 either way. It overshoots far enough to kill the mutant now (an unclamped build reports 102). Also: the neutral ArmChannelBusy contract said the return value is the caller's denominator, which the MediaTek path contradicts by returning the request unchanged — the contract now describes both shapes and points at ChannelBusy::window_us as the one authoritative window. Spoil precedence agrees across families (an interruption outranks "not elapsed", because the short interval is a symptom of it). The probe emits JSON Lines through the shared EventSink instead of a bespoke text format, adopts the libusb context, and its frame literal no longer relies on implicit zeros. The harness picks the family numerically rather than by the spelling of the VID, runs the load-free spoil arms on both families, and three of its assertions could pass vacuously — an empty extractor result, a stale arm whose records never appeared, and a spread comparison that a handful of unlucky 2 ms samples can satisfy by being uniformly wrong. Each fails loudly now. The root CLAUDE.md keeps a pointer rather than a second copy of a contract documented on IRadio. 69/69 ctest, ASan+UBSan clean on both selftests. On air: the Realtek harness passes steady and bursty on an 8812AU (armed mean 9% spread 19 against the sampled read's 75, which missed the load in 5 of 6 windows), and the MediaTek sensor now passes the quiet floor plus the retune and early-read refusals. The ready-bit assumption every Realtek refusal rests on is checked on air by the early and stale arms, 6/6 each, and that is recorded in docs/rx-spectrum-sensing.md rather than asserted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SbJ75HT4fqof7e5e8NKL2j --- CLAUDE.md | 14 +-- CMakeLists.txt | 12 +-- docs/rx-spectrum-sensing.md | 11 +++ src/BusyWindow.h | 16 +-- src/IRtlRadio.h | 6 +- src/RxSense.h | 62 ++++++++++++ src/jaguar1/RtlJaguarDevice.cpp | 49 +++++++--- src/jaguar2/RtlJaguar2Device.cpp | 36 ++++--- src/jaguar3/RtlJaguar3Device.cpp | 7 +- src/mt7612u/CLAUDE.md | 6 +- src/mt7612u/Mt7612uRadio.cpp | 106 +++++++++++++------- src/mt7612u/Mt7612uRadio.h | 12 ++- src/mt7612u/include/mt7612u/mt7612u.h | 12 +++ src/mt7612u/init.cpp | 21 +++- src/mt7612u/internal.h | 6 ++ src/mt7612u/tests/api_link.c | 2 + tests/busy_window_probe.cpp | 84 +++++++++++----- tests/busy_window_probe.sh | 134 ++++++++++++++++++++------ tests/busy_window_selftest.cpp | 30 +++++- tests/channel_busy_selftest.cpp | 101 +++++++++++++++++++ 20 files changed, 564 insertions(+), 163 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ec422a40..a1592d5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -463,15 +463,11 @@ where DIG is not free to walk the gain out from under it (the per-generation windows are in each `src//CLAUDE.md`); and the ~2 ms window makes one dwell a sample, not a measurement. -That last one is what `IRadio::ArmChannelBusy(window_us)` addresses: arm a -window where you reset your counters, read it when your dwell ends, and the -busy figure covers that window instead of a 2 ms slice of it. Both silicon -families implement it (Realtek arms CLM alone, MediaTek resets its channel -timers), and a window that something else disturbed — an NHM read re-arming the -shared engine, a retune, a read before it elapsed — comes back INVALID carrying -its reason rather than a plausible number. Own transmission is reported, not -corrected: the two families are biased in opposite directions by it. Which map -truncates and which merely inflates, and every measured number, live in each +That last one is what `IRadio::ArmChannelBusy(window_us)` addresses: a busy +figure over the caller's own window instead of a 2 ms slice of it. The contract +— what arming promises, what invalidates a window, and what own transmission +does to the reading — is documented once, on the declaration in `src/IRadio.h`. +The per-generation behaviour and every measured number live in each `src//CLAUDE.md` and `docs/rx-spectrum-sensing.md`. Both are **emitted, not scored**: neither `ChannelScore` nor the hopset occupancy law reads them. Measured numbers, the generation matrix and the diff --git a/CMakeLists.txt b/CMakeLists.txt index 7e3f8da6..9c1fee99 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -837,6 +837,12 @@ target_link_libraries(chipstate PUBLIC devourer PRIVATE PkgConfig::libusb) # The in-tree caller for the carrier-sense gate split. Needs hardware, so it is # a tool rather than an add_test — tests/cca_gates_regcheck.sh drives it and # cross-checks the registers with chipstate. +add_executable(CcaGatesProbe + tests/cca_gates_probe.cpp +) +target_include_directories(CcaGatesProbe PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/examples/common) +target_link_libraries(CcaGatesProbe PUBLIC devourer PRIVATE PkgConfig::libusb) + # On-air driver for IRadio::ArmChannelBusy, through the public contract only # (arm + GetChannelBusy). tests/busy_window_probe.sh runs its arms and asserts # the separations; a spoiled window must come back INVALID with its reason, @@ -847,12 +853,6 @@ add_executable(BusyWindowProbe target_include_directories(BusyWindowProbe PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/examples/common) target_link_libraries(BusyWindowProbe PUBLIC devourer PRIVATE PkgConfig::libusb) -add_executable(CcaGatesProbe - tests/cca_gates_probe.cpp -) -target_include_directories(CcaGatesProbe PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/examples/common) -target_link_libraries(CcaGatesProbe PUBLIC devourer PRIVATE PkgConfig::libusb) - # Headless regression guard for the binary-stdin framing shared by the two # stream demos above (examples/common/stream_stdin.h). No libusb, no radio — just the # set_stdin_binary() + read_exact() path, so a text-mode regression (e.g. the diff --git a/docs/rx-spectrum-sensing.md b/docs/rx-spectrum-sensing.md index a7dbdc27..5700ac79 100644 --- a/docs/rx-spectrum-sensing.md +++ b/docs/rx-spectrum-sensing.md @@ -420,6 +420,17 @@ invalid with a reason (`ChannelBusy::spoil`) rather than plausible: | a retune mid-window | 60-62% where the channel was 71% | 44-47% where it was 61% | | reading before it elapsed | the result register latches the PREVIOUS window; reads are non-destructive, so an early read is a stale number wearing a fresh timestamp | +The third row rests on one silicon behaviour nothing else here depends on: +**triggering CLM clears the ready bit**, so a window that has not finished +reports not-ready instead of the previous window's result. The headless test +can only model that (its mock drops the bit on the trigger write), so it is +checked on air by two arms, 6/6 each on an 8812AU: `early` (arm, read at once) +returned `spoil=not-elapsed` on every read, and `stale` (arm, let it complete, +read it, arm again, read at once) returned a valid measurement for the first +read of every pair and `not-elapsed` for every second one. Had the trigger +left the bit set, that second read would have returned the first window's +value — valid, and wrong. + The first row is why the rule is enforced on every family and not only where it fails loudly: `GetRxQuality()` calls `GetRxEnergy(with_nhm=true)`, so a consumer polling link quality inside its own survey dwell spoils it without diff --git a/src/BusyWindow.h b/src/BusyWindow.h index 6fb5dffd..42c04519 100644 --- a/src/BusyWindow.h +++ b/src/BusyWindow.h @@ -109,6 +109,16 @@ class ClmWindow { return b; armed_ = false; + /* An earlier reason wins: a window that something re-armed mid-flight is + * "interrupted", and the fact that it is consequently not elapsed either + * is a symptom of that, not a second finding. Checked BEFORE the ready + * bit for exactly that reason. */ + if (spoil_ != BusySpoil::None) { + last_spoil_ = spoil_; + b.spoil = spoil_; + return b; + } + const ClmRead c = read_clm_only(regs, read32); if (!c.ready) { /* The ready bit is still clear: the window has not finished. The result @@ -119,12 +129,6 @@ class ClmWindow { b.spoil = spoil_; return b; } - if (spoil_ != BusySpoil::None) { - last_spoil_ = spoil_; - b.spoil = spoil_; - return b; - } - b.valid = true; b.source = BusySource::Clm; b.valid_busy = true; diff --git a/src/IRtlRadio.h b/src/IRtlRadio.h index 98ba4b5b..3bfe016d 100644 --- a/src/IRtlRadio.h +++ b/src/IRtlRadio.h @@ -86,9 +86,9 @@ class IRtlRadio : public IRadio { * COST + CONTENTION: see the IRadio declaration. This arms the ~2 ms NHM * window and consumes the same delta GetRxEnergy and GetRxQuality read. */ devourer::ChannelBusy GetChannelBusy() override { - /* Sampled OUTSIDE the CCX lock: GetTxStats may take a family lock of its - * own, and the ordering rule here is register lock -> CCX lock, never the - * reverse. */ + /* Sampled OUTSIDE the CCX lock. On these backends GetTxStats is a + * lock-free counter read, but the rule stands on the ordering, not on + * that: nothing may reach for another lock while holding this one. */ const uint64_t tx = GetTxStats().submitted; devourer::ChannelBusy armed_reading; bool was_armed = false; diff --git a/src/RxSense.h b/src/RxSense.h index ebd5fea2..4f933b79 100644 --- a/src/RxSense.h +++ b/src/RxSense.h @@ -261,6 +261,68 @@ inline ChannelBusy busy_from_ch_time(uint32_t busy, uint32_t idle, return b; } +/* An armed MediaTek busy window, as far as the conversion below needs to know + * about it. The backend owns the lifetime; this is the snapshot it decides on. + * + * These timers have no ready bit, so everything that makes an armed reading + * honest has to be carried in software: what the caller asked for (or a short + * glance at the channel reads as a finished window), whether a retune ran + * through it, and the TX baseline, because unlike CLM these timers count own + * transmission as busy. */ +struct ChTimeWindow { + bool armed = false; + bool spoiled = false; /* a retune ran through the window */ + uint32_t window_us = 0; /* what the caller requested at arm */ + uint64_t tx_at_arm = 0; +}; + +/* The armed-window decision for the MediaTek timers, pure so the refusals are + * reachable from a selftest with no radio — the backend that owns the state is + * hardware-only, and these rules are exactly the part worth testing. + * + * `disturbed` says mt7612u_link_stats() read-and-cleared the same registers + * inside the window, which takes the counts this reading would otherwise + * claim: the MediaTek equivalent of an NHM read re-arming the shared CCX + * engine, and reported the same way. + * + * An unarmed call is the sampled path and falls through to busy_from_ch_time + * unchanged. */ +inline ChannelBusy busy_from_ch_time_window(const ChTimeWindow &w, + uint32_t busy, uint32_t idle, + uint32_t interval_us, + bool disturbed, uint64_t tx_now) { + if (w.armed) { + if (w.spoiled) { + ChannelBusy b; + b.spoil = BusySpoil::Retuned; + return b; + } + /* Interruption outranks "not elapsed", matching the Realtek ordering in + * devourer::ClmWindow::read: when something took the counters, the short + * interval is a SYMPTOM of that, and the caller's fix is its own + * sequencing rather than a longer wait. */ + if (disturbed) { + ChannelBusy b; + b.spoil = BusySpoil::Interrupted; + return b; + } + if (interval_us < w.window_us) { + ChannelBusy b; + b.spoil = BusySpoil::NotElapsed; + return b; + } + } + ChannelBusy b = busy_from_ch_time(busy, idle, interval_us); + if (w.armed && b.valid) { + const uint64_t sent = tx_now > w.tx_at_arm ? tx_now - w.tx_at_arm : 0; + b.own_tx_frames = sent > UINT32_MAX ? UINT32_MAX + : static_cast(sent); + b.own_tx_in_window = sent > 0; + } + return b; +} + + } /* namespace devourer */ #endif /* RX_SENSE_H */ diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index 48b109d1..a3613e90 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -1923,11 +1923,17 @@ void RtlJaguarDevice::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { void RtlJaguarDevice::SetMonitorChannel(SelectedChannel channel) { /* A window armed before this retune would integrate across the channel * change and report the blend as one channel's occupancy. */ - /* Held ACROSS the tune, not just around the note: a window armed in the gap - * between the two would integrate across the channel change and read back - * valid. Ordering is the family's register lock first, then this one. */ - std::lock_guard ccx(busy_window_mutex()); - busy_window_note_retune(); + /* Scoped to the note, NOT held across the tune. Holding it deadlocks: when + * the fast path declines, the fallback calls SetMonitorChannel(), which + * takes this same non-recursive mutex again. The note is safe scoped + * because the control plane is single-threaded by contract (see + * IRadio::ArmChannelBusy) — and on the families that DO have a register + * lock, that lock spans the tune and with_ccx takes it first, which closes + * the gap there for free. */ + { + std::lock_guard ccx(busy_window_mutex()); + busy_window_note_retune(); + } /* Keep the device-level channel state current: send_packet's 5GHz * CCK->OFDM clamp keys off _channel.Channel. Before this assignment * existed, _channel was never written anywhere — the clamp read an @@ -1955,11 +1961,17 @@ int RtlJaguarDevice::GetRxPathMask() { void RtlJaguarDevice::FastRetune(uint8_t channel, bool cache_rf) { /* A window armed before this retune would integrate across the channel * change and report the blend as one channel's occupancy. */ - /* Held ACROSS the tune, not just around the note: a window armed in the gap - * between the two would integrate across the channel change and read back - * valid. Ordering is the family's register lock first, then this one. */ - std::lock_guard ccx(busy_window_mutex()); - busy_window_note_retune(); + /* Scoped to the note, NOT held across the tune. Holding it deadlocks: when + * the fast path declines, the fallback calls SetMonitorChannel(), which + * takes this same non-recursive mutex again. The note is safe scoped + * because the control plane is single-threaded by contract (see + * IRadio::ArmChannelBusy) — and on the families that DO have a register + * lock, that lock spans the tune and with_ccx takes it first, which closes + * the gap there for free. */ + { + std::lock_guard ccx(busy_window_mutex()); + busy_window_note_retune(); + } if (_radioManagement->fast_retune(channel, cache_rf)) { _channel.Channel = channel; return; @@ -1973,10 +1985,19 @@ void RtlJaguarDevice::FastRetune(uint8_t channel, bool cache_rf) { void RtlJaguarDevice::FastSetBandwidth(ChannelWidth_t bw) { /* A bandwidth change re-clocks the front end, so a window armed before it - * was measuring a different receiver — the same argument as a retune. Held - * across the change, not just noted before it. */ - std::lock_guard ccx(busy_window_mutex()); - busy_window_note_retune(); + * was measuring a different receiver — the same argument as a retune. + * + * ONE rule, two shapes: the note must not be separable from the change by a + * concurrent arm. Where the family has a register lock that spans the + * change (Jaguar2/3), the note sits inside it and with_ccx's ordering does + * the rest. Here there is no such lock, and holding this one across the + * change would deadlock — the declined fast path falls back to + * SetMonitorChannel(), which takes it again — so the note is scoped and the + * single-control-thread contract carries it. */ + { + std::lock_guard ccx(busy_window_mutex()); + busy_window_note_retune(); + } if (_radioManagement->fast_set_bandwidth(bw)) { _channel.ChannelWidth = bw; return; diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index 7d893fe4..bf4f0284 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -907,9 +907,10 @@ void RtlJaguar2Device::SetMonitorChannel(SelectedChannel channel) { * change and report the blend as one channel's occupancy. */ /* Serialize against the thermal-track tick's RF-window read. */ std::lock_guard lk(_reg_mu); - /* Held ACROSS the tune, not just around the note: a window armed in the gap - * between the two would integrate across the channel change and read back - * valid. Ordering is the family's register lock first, then this one. */ + /* The note must not be able to land before a concurrent arm that then + * commits while this tune runs. _reg_mu above is what spans the tune, and + * with_ccx takes _reg_mu BEFORE this lock, so an arm cannot interleave. + * Ordering is always the family's register lock first, then this one. */ std::lock_guard ccx(busy_window_mutex()); busy_window_note_retune(); _channel = channel; @@ -935,19 +936,13 @@ void RtlJaguar2Device::SetMonitorChannel(SelectedChannel channel) { void RtlJaguar2Device::FastRetune(uint8_t channel, bool cache_rf) { if (channel == _channel.Channel) - return; /* no tune, so nothing to spoil — the note goes after this */ - /* A window armed before this retune would integrate across the channel - * change and report the blend as one channel's occupancy. */ - { - std::lock_guard ccx(busy_window_mutex()); - busy_window_note_retune(); - } - + return; /* no tune, so nothing to spoil */ /* Serialize against the thermal-track tick's RF-window read. */ std::lock_guard lk(_reg_mu); - /* Held ACROSS the tune, not just around the note: a window armed in the gap - * between the two would integrate across the channel change and read back - * valid. Ordering is the family's register lock first, then this one. */ + /* The note must not be able to land before a concurrent arm that then + * commits while this tune runs. _reg_mu above is what spans the tune, and + * with_ccx takes _reg_mu BEFORE this lock, so an arm cannot interleave. + * Ordering is always the family's register lock first, then this one. */ std::lock_guard ccx(busy_window_mutex()); busy_window_note_retune(); const bool band_change = (_channel.Channel <= 14) != (channel <= 14); @@ -973,11 +968,14 @@ void RtlJaguar2Device::FastRetune(uint8_t channel, bool cache_rf) { void RtlJaguar2Device::FastSetBandwidth(ChannelWidth_t bw) { { std::lock_guard lk(_reg_mu); - /* A bandwidth change re-clocks the front end, so a window armed before it - * was measuring a different receiver — the same argument as a retune. Held - * across the change, not just noted before it. */ - std::lock_guard ccx(busy_window_mutex()); - busy_window_note_retune(); + /* A bandwidth change re-clocks the front end, so a window armed before it + * was measuring a different receiver — the same argument as a retune. The + * note sits inside _reg_mu, which spans the change, and with_ccx takes + * _reg_mu first, so an arm cannot interleave. The fall-through to + * SetMonitorChannel is deliberately OUTSIDE this scope: it takes both + * locks itself. */ + std::lock_guard ccx(busy_window_mutex()); + busy_window_note_retune(); if (_hal.fast_set_bandwidth(static_cast(bw))) { _channel.ChannelWidth = bw; return; diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index 0865fa74..b4b42f64 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -1377,9 +1377,10 @@ void RtlJaguar3Device::SetMonitorChannel(SelectedChannel channel) { * radio-management core directly (no lock needed: the coex thread isn't * running yet), so locking here cannot self-deadlock. */ std::lock_guard lk(_reg_mu); - /* Held ACROSS the tune, not just around the note: a window armed in the gap - * between the two would integrate across the channel change and read back - * valid. Ordering is the family's register lock first, then this one. */ + /* The note must not be able to land before a concurrent arm that then + * commits while this tune runs. _reg_mu above is what spans the tune, and + * with_ccx takes _reg_mu BEFORE this lock, so an arm cannot interleave. + * Ordering is always the family's register lock first, then this one. */ std::lock_guard ccx(busy_window_mutex()); busy_window_note_retune(); diff --git a/src/mt7612u/CLAUDE.md b/src/mt7612u/CLAUDE.md index 694b4f58..42f26a00 100644 --- a/src/mt7612u/CLAUDE.md +++ b/src/mt7612u/CLAUDE.md @@ -31,8 +31,10 @@ the channel is already saturated (the arm then never becomes available, and the MCU times out), so in a two-adapter test bring this one up BEFORE the interferer. -`ArmChannelBusy` here resets the timers and the interval mark -(`mt7612u_link_stats_start`), so the window is exactly the caller's arm-to-read +`ArmChannelBusy` here resets the timers and the interval mark through +`mt7612u_ch_time_arm` — NOT `mt7612u_link_stats_start`, which would also clear +the MIB block and the link-stats interval that the 1 Hz tick owns, once per +dwell — so the window is exactly the caller's arm-to-read gap and is reported that way in `window_us`. The mark is stamped at arm rather than zeroed — zeroing made the first read after an arm report `window_us=0`, i.e. a percentage with no denominator. diff --git a/src/mt7612u/Mt7612uRadio.cpp b/src/mt7612u/Mt7612uRadio.cpp index c1462af7..6ad8e858 100644 --- a/src/mt7612u/Mt7612uRadio.cpp +++ b/src/mt7612u/Mt7612uRadio.cpp @@ -419,6 +419,11 @@ void Mt7612uRadio::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { _logger->warn("MT7612U monitor RX filter not applied"); /* Arms the channel timers and zeroes the MIB counters. */ mt7612u_link_stats_start(_dev); + /* A window armed before this start was measuring the previous receiver + * session, and that start just re-zeroed the hardware timers it was + * accumulating in. Forget it rather than consume the post-restart + * interval as though it began at the caller's arm. */ + _busy = devourer::ChTimeWindow{}; _rx_active.store(true, std::memory_order_release); } if (mac_failed) { @@ -465,6 +470,12 @@ void Mt7612uRadio::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { } void Mt7612uRadio::StopRxLoop() { + /* The armed window dies with the receiver: the timers stop counting and the + * next start re-zeroes them. */ + { + std::lock_guard lock(_mu); + _busy = devourer::ChTimeWindow{}; + } _rx_stop = true; /* Wake the consumer immediately rather than leaving it to time out. */ _rx_q.wake(); @@ -588,8 +599,8 @@ void Mt7612uRadio::SetMonitorChannel(SelectedChannel channel) { * retune is spoiled either way — re-arming makes the next reading honest, * it does not make the old one about one channel. */ mt7612u_link_stats_start(_dev); - if (_busy_armed) - _busy_spoiled = true; + if (_busy.armed) + _busy.spoiled = true; } SelectedChannel Mt7612uRadio::GetSelectedChannel() { @@ -852,58 +863,79 @@ uint64_t Mt7612uRadio::ReadTsf() { * ranker 0% means "emptiest", so that zero does not merely lose information, * it steers the choice onto the busiest channel. No RX, no reading. */ devourer::ChannelBusy Mt7612uRadio::GetChannelBusy() { - uint32_t busy = 0, idle = 0, interval_us = 0; if (!_rx_active.load(std::memory_order_acquire)) return {}; - { - /* An armed window that a retune ran through counts across the channel - * change, exactly as the Realtek one does, so it is refused with its - * reason rather than reported as this channel's occupancy. */ - std::lock_guard lock(_mu); - if (_busy_armed && _busy_spoiled) { - _busy_armed = false; - _busy_spoiled = false; - devourer::ChannelBusy spoiled; - spoiled.spoil = devourer::BusySpoil::Retuned; - return spoiled; - } - _busy_armed = false; - } - { - /* The 1 Hz tick thread holds _mu inside mt7612u_phy_tick, which issues MCU - * commands; serialising here keeps register access single-file the way - * every other accessor on this class does. */ - std::lock_guard lock(_mu); - /* mt7612u_ch_time refuses while the timers are unarmed, which is the case - * before bring-up and is what keeps a previous session's register residue - * from being reported as an idle channel. Arming happens in both the RX - * and the transmit-only path, and again on every live retune. */ - if (!_dev || mt7612u_ch_time(_dev, &busy, &idle, &interval_us) != 0) - return {}; + + /* ONE locked section, start to finish. An earlier cut cleared the armed + * flag in one critical section and read the timers in another; a retune + * landing in that gap found nothing to spoil and the post-retune remainder + * came back valid. */ + std::lock_guard lock(_mu); + /* Re-checked UNDER the lock: StopRxLoop can finish between the cheap check + * above and here, and the C layer would still answer (ch_time_armed stays + * set), handing back a plausible reading for a receiver that is down — the + * fabricated zero this gate exists to prevent. */ + if (!_dev || !_rx_active.load(std::memory_order_acquire)) + return {}; + + const devourer::ChTimeWindow window = _busy; + if (_busy.armed) + _busy = devourer::ChTimeWindow{}; /* consumed, whatever the verdict */ + + uint32_t busy = 0, idle = 0, interval_us = 0; + if (mt7612u_ch_time(_dev, &busy, &idle, &interval_us) != 0) { + /* The window is already consumed above, so say the register read took it + * rather than returning the bare "this backend has no sensor" answer a + * default-constructed reading means. */ + devourer::ChannelBusy failed; + if (window.armed) + failed.spoil = devourer::BusySpoil::Interrupted; + return failed; } - return devourer::busy_from_ch_time(busy, idle, interval_us); + /* Only a C-API caller that also polls mt7612u_link_stats() can steal these + * counters (tools/bringup.cpp does; nothing on this path does), but the + * window is only honest if that is checked rather than assumed. */ + const bool disturbed = mt7612u_ch_time_disturbed(_dev) != 0; + + /* The rules themselves are pure and live next to the conversion they + * qualify (devourer::busy_from_ch_time_window), so the refusals are + * reachable from a selftest even though this backend needs a radio. */ + return devourer::busy_from_ch_time_window( + window, busy, idle, interval_us, disturbed, + _tx_submitted.load(std::memory_order_relaxed)); } /* IRadio::ArmChannelBusy on MediaTek: the channel timers already integrate * over the whole interval between reads, so "arming" is resetting them and - * the interval mark — which is what mt7612u_link_stats_start() does. The + * the interval mark — which is what mt7612u_ch_time_arm() does, deliberately + * NOT mt7612u_link_stats_start(): that one also clears the MIB block and the + * link-stats interval, which belong to the 1 Hz telemetry caller. The * window therefore ends up exactly as long as the caller's own arm-to-read * gap, and is reported that way (window_us from the host-measured interval) * rather than as the requested length. * - * Returns the REQUESTED window, which is the honest answer here: nothing is - * programmed into hardware, so nothing clamps it. A caller that needs the - * window that actually elapsed reads ChannelBusy::window_us. */ + * Returns the REQUESTED window, which is what the IRadio contract asks for + * where nothing is programmed into hardware: these timers just run, so there + * is no period to clamp. The window that actually elapsed is reported on the + * reading (ChannelBusy::window_us), as on every family. */ uint32_t Mt7612uRadio::ArmChannelBusy(uint32_t window_us) { - if (!_rx_active.load(std::memory_order_acquire)) + /* Same rule the Realtek path follows (devourer::ClmWindow::arm): a zero + * window is a caller bug, not a request for the minimum. Returning 0 while + * having armed anyway would be the worst of both — the caller falls back to + * the sampled path, and its next GetChannelBusy() silently takes the armed + * branch and reports a window it never asked for. */ + if (window_us == 0) return 0; std::lock_guard lock(_mu); - if (!_dev) + /* Checked under the lock for the same reason the read is. */ + if (!_dev || !_rx_active.load(std::memory_order_acquire)) return 0; if (mt7612u_ch_time_arm(_dev) != 0) return 0; - _busy_armed = true; - _busy_spoiled = false; + _busy = devourer::ChTimeWindow{}; + _busy.armed = true; + _busy.window_us = window_us; + _busy.tx_at_arm = _tx_submitted.load(std::memory_order_relaxed); return window_us; } diff --git a/src/mt7612u/Mt7612uRadio.h b/src/mt7612u/Mt7612uRadio.h index 1198670e..e76c531b 100644 --- a/src/mt7612u/Mt7612uRadio.h +++ b/src/mt7612u/Mt7612uRadio.h @@ -149,11 +149,13 @@ class Mt7612uRadio : public IRadio { std::mutex _teardown_mu; std::atomic _rx_stop{false}; std::atomic _rx_active{false}; - /* An armed busy window (IRadio::ArmChannelBusy) and whether a retune - * invalidated it. Guarded by _mu like every other register-adjacent member; - * the atomic above is separate because Stop() reads it without the lock. */ - bool _busy_armed = false; - bool _busy_spoiled = false; + /* An armed busy window (IRadio::ArmChannelBusy). All of it is guarded by + * _mu, and every field is needed for the reading to be honest: the + * requested length so a premature read is refused rather than reported as a + * short window (the elapsed side comes from the C layer's own interval + * mark), and the TX baseline because these timers count own transmission as + * busy. */ + devourer::ChTimeWindow _busy; std::atomic _rx_frames{0}; /* Frames cross from the C library's event thread to the StartRxLoop thread diff --git a/src/mt7612u/include/mt7612u/mt7612u.h b/src/mt7612u/include/mt7612u/mt7612u.h index 4efa28a0..9a393ccb 100644 --- a/src/mt7612u/include/mt7612u/mt7612u.h +++ b/src/mt7612u/include/mt7612u/mt7612u.h @@ -457,6 +457,18 @@ int mt7612u_link_stats(struct mt7612u_dev *dev, struct mt7612u_link_stats *out); int mt7612u_ch_time(struct mt7612u_dev *dev, uint32_t *busy, uint32_t *idle, uint32_t *interval_us); +/* Reports, and clears, whether mt7612u_link_stats() read-and-cleared the + * channel timers since they were armed — i.e. whether that call took the + * counts a ch_time() reading would otherwise claim. The two share + * MT_CH_BUSY/MT_CH_IDLE and both clear on read. + * + * Nothing in Mt7612uRadio polls link_stats(), so this cannot fire through the + * IRadio path; it exists for a C-API caller that uses BOTH (tools/bringup.cpp + * does), where a window measured across such a poll would otherwise report + * the remainder as a full reading. A caller measuring one window treats it as + * a spoiled window, never as a quiet channel. */ +int mt7612u_ch_time_disturbed(struct mt7612u_dev *dev); + int mt7612u_phy_tick(struct mt7612u_dev *dev); /* diff --git a/src/mt7612u/init.cpp b/src/mt7612u/init.cpp index 6dc9479d..a5b8a5d0 100644 --- a/src/mt7612u/init.cpp +++ b/src/mt7612u/init.cpp @@ -599,7 +599,8 @@ int mt7612u_link_stats_start(struct mt7612u_dev *d) MT_CH_TIME_CFG_RX_AS_BUSY | MT_CH_TIME_CFG_NAV_AS_BUSY | MT_CH_TIME_CFG_EIFS_AS_BUSY | MT_CH_CCA_RC_EN | FIELD_PREP(MT_CH_TIME_CFG_CH_TIMER_CLR, 1)); - /* One read to clear everything, so the first real sample is clean. */ + /* One read to clear everything, so the first real sample is clean. Its + * own disturbance flag is cleared below: arming is not a theft. */ mt7612u_link_stats(d, &discard); /* Arming is what makes a ch_time reading meaningful, so the interval * STARTS here — not at whatever the previous session left behind, and not @@ -637,10 +638,23 @@ int mt7612u_ch_time_arm(struct mt7612u_dev *d) if (mt_rr_chk(d, MT_CH_BUSY, &discard) || mt_rr_chk(d, MT_CH_IDLE, &discard)) return -1; d->ch_time_armed = 1; + d->ch_time_disturbed = 0; d->ch_time_last_us = stats_now_us(); return 0; } +/* Did anything read-and-clear the channel timers behind an armed window? See + * the header. Reports and clears. */ +int mt7612u_ch_time_disturbed(struct mt7612u_dev *d) +{ + int was; + + if (!d) return 0; + was = d->ch_time_disturbed; + d->ch_time_disturbed = 0; + return was; +} + /* Channel timers only. See the header for why this is not link_stats(). */ int mt7612u_ch_time(struct mt7612u_dev *d, uint32_t *busy, uint32_t *idle, uint32_t *interval_us) @@ -682,6 +696,11 @@ int mt7612u_link_stats(struct mt7612u_dev *d, struct mt7612u_link_stats *out) out->ch_busy = mt_rr(d, MT_CH_BUSY); out->ch_idle = mt_rr(d, MT_CH_IDLE); + /* Those two are read-and-clear and are the SAME registers a ch_time + * window accumulates in, so this poll just took its counts. Flag it + * rather than let the window report the remainder as a full reading. */ + if (d->ch_time_armed) + d->ch_time_disturbed = 1; v = mt_rr(d, MT_RX_STAT_0); out->rx_crc_err = (uint16_t)FIELD_GET(MT_RX_STAT_0_CRC_ERRORS, v); diff --git a/src/mt7612u/internal.h b/src/mt7612u/internal.h index 02649301..a55a579d 100644 --- a/src/mt7612u/internal.h +++ b/src/mt7612u/internal.h @@ -211,6 +211,12 @@ struct mt7612u_dev { uint16_t max_mpdu_rx; /* from MT_MAX_LEN_CFG at init, less the FCS */ uint64_t stats_last_us; /* previous mt7612u_link_stats() mark */ int ch_time_armed; /* channel timers configured and zeroed */ + /* Set when mt7612u_link_stats() cleared the channel timers while a + * ch_time window was armed: the two share MT_CH_BUSY/MT_CH_IDLE, which + * are read-and-clear, so the telemetry poll takes the counts the window + * was accumulating. Without this the later read covers only the + * remainder while still claiming the full interval. */ + int ch_time_disturbed; uint64_t ch_time_last_us; /* previous mt7612u_ch_time() mark — separate * from stats_last_us for the same reason it * is per-device: two readers sharing one mark diff --git a/src/mt7612u/tests/api_link.c b/src/mt7612u/tests/api_link.c index 2f3d17e8..7f07624c 100644 --- a/src/mt7612u/tests/api_link.c +++ b/src/mt7612u/tests/api_link.c @@ -41,6 +41,8 @@ static void *const api[] = { (void *)mt7612u_link_stats_start, (void *)mt7612u_link_stats, (void *)mt7612u_ch_time, + (void *)mt7612u_ch_time_arm, + (void *)mt7612u_ch_time_disturbed, (void *)mt7612u_phy_tick, (void *)mt7612u_read_tsf_chk, (void *)mt7612u_read_tsf, diff --git a/tests/busy_window_probe.cpp b/tests/busy_window_probe.cpp index 22ca9921..fc46f981 100644 --- a/tests/busy_window_probe.cpp +++ b/tests/busy_window_probe.cpp @@ -27,9 +27,10 @@ * ready bit - an assumption the headless selftest can only model, * which is why it is checked here against real silicon. * race a second thread hammers GetRxQuality() for the whole window while - * this one arms and reads. Every reading must be either refused as - * interrupted or a full-length window — never a short one wearing a - * valid flag. This is the on-air check of the locking: without it + * this one arms and reads. Every reading must come back refused as + * interrupted — the hammer re-arms the shared engine continuously, + * so no window survives it, and the failure being hunted is a SHORT + * window wearing a valid flag. This is the on-air check of the locking: without it * the note could land before a concurrent arm while the re-arm * lands after it, and a destroyed window reads back as data. * quality like `interrupt`, but through GetRxQuality() instead of @@ -57,6 +58,7 @@ #endif #include "AdapterCaps.h" +#include "Event.h" #include "DeviceSession.h" #include "IRtlRadio.h" #include "RadiotapBuilder.h" @@ -95,16 +97,27 @@ const char *source_name(devourer::BusySource s) { } } +devourer::EventSink g_ev; + +/* JSON Lines through the shared sink, like every other machine-readable + * output in the tree: one atomic write per line, so a consumer parsing stdout + * (or a second writer on it) is never handed a half-line or a bespoke + * format. */ void emit(const char *mode, int i, const devourer::ChannelBusy &b, uint32_t armed_us, uint32_t sent) { - /* No padding inside a key=value token: the harness parses these fields, and - * "busy= 71%" splits into two. */ - std::printf("BUSY mode=%-9s i=%2d valid=%d busy=%u%% src=%-6s window_us=%u " - "armed_us=%u spoil=%-11s own_tx=%d tx_frames=%u sent=%u\n", - mode, i, b.valid ? 1 : 0, b.busy_pct, source_name(b.source), - b.window_us, armed_us, spoil_name(b.spoil), - b.own_tx_in_window ? 1 : 0, b.own_tx_frames, sent); - std::fflush(stdout); + devourer::Ev(g_ev, "busy.window") + .t() + .f("mode", mode) + .f("i", static_cast(i)) + .f("valid", b.valid) + .f("busy_pct", static_cast(b.busy_pct)) + .f("source", source_name(b.source)) + .f("window_us", static_cast(b.window_us)) + .f("armed_us", static_cast(armed_us)) + .f("spoil", spoil_name(b.spoil)) + .f("own_tx", b.own_tx_in_window) + .f("tx_frames", static_cast(b.own_tx_frames)) + .f("sent", static_cast(sent)); } /* Every exit from main() after the RX thread is running must go through this. @@ -112,7 +125,10 @@ void emit(const char *mode, int i, const devourer::ChannelBusy &b, * DeviceSession's destructor close the handle under it is a use-after-free — * which is exactly what the SKIP returns used to do. _exit runs no * destructors, so the USB lock is dropped by hand first: one left behind makes - * the next run of this script refuse the adapter it just used. */ + * the next run of this script refuse the adapter it just used. The libusb + * context is NOT closed on that path — the kernel reclaims it at process exit, + * and closing it under a live RX thread is the very hazard being avoided. The + * non-RX paths return normally and the session closes everything. */ struct Cleanup { IRadio *dev = nullptr; std::shared_ptr *lock = nullptr; @@ -167,6 +183,7 @@ int main(int argc, char **argv) { } } + g_ev.configure(stdout); auto logger = std::make_shared(); libusb_context *ctx = nullptr; if (libusb_init(&ctx) < 0) { @@ -174,6 +191,8 @@ int main(int argc, char **argv) { return 3; } devourer::DeviceSession session(logger); + /* So every exit path, including the early ones, runs libusb_exit(). */ + session.adopt_context(ctx); libusb_device_handle *handle = libusb_open_device_with_vid_pid(ctx, vid, pid); if (!handle) { std::fprintf(stderr, "no adapter %04x:%04x\n", vid, pid); @@ -201,12 +220,13 @@ int main(int argc, char **argv) { auto *const rtl = dynamic_cast(dev); const devourer::AdapterCaps caps = dev->GetAdapterCaps(); - std::printf("BUSY-GEN %s chip=%s busy_airtime_ok=%d busy_airtime_measured=%d " - "rx_energy_ok=%d\n", - devourer::generation_name(caps.generation), caps.chip_name, - caps.busy_airtime_ok ? 1 : 0, caps.busy_airtime_measured ? 1 : 0, - caps.rx_energy_ok ? 1 : 0); - std::fflush(stdout); + devourer::Ev(g_ev, "busy.caps") + .t() + .f("generation", devourer::generation_name(caps.generation)) + .f("chip", caps.chip_name) + .f("busy_airtime_ok", caps.busy_airtime_ok) + .f("busy_airtime_measured", caps.busy_airtime_measured) + .f("rx_energy_ok", caps.rx_energy_ok); const SelectedChannel chan_def{.Channel = static_cast(channel), .ChannelOffset = 0, @@ -265,7 +285,7 @@ int main(int argc, char **argv) { const uint32_t armed = dev->ArmChannelBusy(window_us); if (armed == 0) { - std::printf("SKIP backend cannot arm a busy window\n"); + devourer::Ev(g_ev, "busy.skip").t().f("why", "cannot arm a busy window"); return finish(5); } /* Wait the window the hardware actually granted, not the one requested. */ @@ -286,9 +306,14 @@ int main(int argc, char **argv) { } else if (mode == "race") { std::atomic stop{false}; std::thread hammer([&]() { + /* Register I/O can throw on a USB glitch. An escaping exception here + * terminates the process instead of failing the arm under test. */ while (!stop.load(std::memory_order_relaxed)) { - if (rtl) - (void)rtl->GetRxQuality(); + try { + if (rtl) + (void)rtl->GetRxQuality(); + } catch (const std::exception &) { + } } }); nap(wait_ms); @@ -308,15 +333,22 @@ int main(int argc, char **argv) { const devourer::ChannelBusy done = dev->GetChannelBusy(); emit("stale-1st", i, done, armed, 0); if (dev->ArmChannelBusy(window_us) == 0) { - std::printf("SKIP second arm refused\n"); + devourer::Ev(g_ev, "busy.skip").t().f("why", "second arm refused"); return finish(5); } nap(5); } else if (mode == "txsess") { - static const uint8_t dot11[36] = {0x08, 0x00, 0x00, 0x00, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0x02, 0x11, - 0x22, 0x33, 0x44, 0x55, 0x02, 0x11, - 0x22, 0x33, 0x44, 0x55}; + /* A 24-byte 802.11 data header, written out in full: frame control, + * duration, addr1/2/3 and the sequence-control field. An earlier cut + * listed 22 bytes into a 36-byte array and worked only because the + * implicit zeros happened to land where seq-ctrl belongs. */ + static const uint8_t dot11[24] = { + 0x08, 0x00, /* frame control: data */ + 0x00, 0x00, /* duration */ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, /* addr1: broadcast */ + 0x02, 0x11, 0x22, 0x33, 0x44, 0x55, /* addr2: source */ + 0x02, 0x11, 0x22, 0x33, 0x44, 0x55, /* addr3: BSSID */ + 0x00, 0x00}; /* sequence control */ devourer::TxMode tx_mode; const std::vector rt = devourer::build_stream_radiotap(tx_mode); std::vector buf(rt.begin(), rt.end()); diff --git a/tests/busy_window_probe.sh b/tests/busy_window_probe.sh index d6bb280c..d7d7e57f 100755 --- a/tests/busy_window_probe.sh +++ b/tests/busy_window_probe.sh @@ -15,7 +15,9 @@ # sampled unarmed, same load -- the spread comparison. # interrupt NHM read mid-window -- must be INVALID, spoil=interrupted. # retune retune mid-window -- must be INVALID, spoil=retuned. -# early read before elapsed -- must be INVALID, spoil=not-elapsed. +# early read before elapsed -- must be INVALID, spoil=not-elapsed +# (both families: Realtek from the CCX ready bit, MediaTek from +# the window length recorded at arm). # txsess sensor transmits -- must stay VALID and be flagged own_tx. # # sudo tests/busy_window_probe.sh @@ -94,8 +96,15 @@ assert_flood_alive() { # label } busy_values() { # mode -> one busy percentage per valid sample - grep -E "^BUSY mode" "$OUT/$1.log" 2>/dev/null | grep "valid=1" \ - | sed -E 's/.*busy=([0-9]+)%.*/\1/' + python3 -c " +import json,sys +for line in open(sys.argv[1]): + line = line.strip() + if not line.startswith('{'): continue + try: r = json.loads(line) + except ValueError: continue + if r.get('ev') == 'busy.window' and r.get('valid'): print(r['busy_pct']) +" "$OUT/$1.log" 2>/dev/null } stat_of() { # mode, mean|min|max|spread @@ -120,13 +129,28 @@ assert_ge() { # label, value, bound else echo "FAIL $1: $2 < $3"; fails=$((fails + 1)); fi } +# The probe emits JSON Lines (ev=busy.window / busy.caps / busy.skip), so the +# assertions below read fields out of the JSON rather than scraping a bespoke +# text format. +jq_field() { # file, field -> one value per busy.window record + python3 -c " +import json,sys +for line in open(sys.argv[1]): + line = line.strip() + if not line.startswith('{'): continue + try: r = json.loads(line) + except ValueError: continue + if r.get('ev') == 'busy.window': print(r.get(sys.argv[2])) +" "$1" "$2" +} + run_arm() { # mode, label local mode="$1" label="$2" echo "== $label" timeout 120 "$PROBE" --vid "$SENSOR_VID" --pid "$SENSOR_PID" \ --channel "$CHANNEL" --other "$OTHER" --reps "$REPS" \ --window-ms "$WINDOW_MS" --mode "$mode" $rx_flag 2>/dev/null \ - | tee "$OUT/$mode.log" | grep -E "^BUSY" + | tee "$OUT/$mode.log" | grep -E '"ev":"busy\.' } # A spoiled arm must produce NO valid readings; a working arm must produce @@ -138,17 +162,28 @@ expect_all() { # mode, valid(0|1), [spoil] # `grep -c` prints 0 and EXITS 1 when it matches nothing, so a `|| echo 0` # here appended a second line and made this guard dead: an empty log then # reported "ok: 0 samples". The whole point of the guard is the empty case. - n=$(grep -cE "^BUSY mode" "$OUT/$mode.log" 2>/dev/null); n=${n:-0} + n=$(jq_field "$OUT/$mode.log" valid | wc -l); n=${n:-0} if [ "$n" -eq 0 ]; then echo "FAIL $mode: no samples"; fails=$((fails + 1)); return fi - bad=$(grep -E "^BUSY mode" "$OUT/$mode.log" | grep -vc "valid=$want_valid") + local want_json="False"; [ "$want_valid" = "1" ] && want_json="True" + # `grep -vc` on an empty stream prints 0 but a FAILED extractor prints + # nothing at all, and `[ "" -ne 0 ]` is a bash error, not false — which + # skipped the whole if-body and printed "ok". Default to a sentinel so a + # broken extractor fails loudly instead of passing silently. + bad=$(jq_field "$OUT/$mode.log" valid | grep -vc "^$want_json$"); bad=${bad:-ERR} + if [ "$bad" = "ERR" ]; then + echo "FAIL $mode: could not read the probe's records" + fails=$((fails + 1)); return + fi if [ "$bad" -ne 0 ]; then echo "FAIL $mode: $bad/$n samples not valid=$want_valid" fails=$((fails + 1)) fi if [ -n "$want_spoil" ]; then - bad=$(grep -E "^BUSY mode" "$OUT/$mode.log" | grep -vc "spoil=$want_spoil") + bad=$(jq_field "$OUT/$mode.log" spoil | grep -vc "^$want_spoil$"); bad=${bad:-ERR} + [ "$bad" = "ERR" ] && { echo "FAIL $mode: could not read spoil reasons" + fails=$((fails + 1)); return; } [ "$bad" -eq 0 ] || { echo "FAIL $mode: $bad/$n samples not spoil=$want_spoil" fails=$((fails + 1)); } @@ -160,8 +195,11 @@ echo "== sensor $SENSOR_VID:$SENSOR_PID, flooder $FLOOD_VID:$FLOOD_PID, ch$CHANN # The Realtek-only arms drive IRtlRadio facilities (the NHM read, the CCX # result latch). A MediaTek sensor runs the rest. +# Compared numerically: a literal string test made SENSOR_VID=0x0E8D (or a +# decimal VID) run the Realtek-only NHM arms against a MediaTek sensor, where +# the cast fails and the window comes back valid. realtek_sensor=1 -[ "$SENSOR_VID" = "0x0e8d" ] && realtek_sensor=0 +[ "$(printf '%d' "$SENSOR_VID")" -eq "$(printf '%d' 0x0e8d)" ] && realtek_sensor=0 stop_flood run_arm window "quiet floor (no flooder)" @@ -178,6 +216,14 @@ assert_le "quiet floor is quiet" "$quiet_mean" 5 # is one process order this script cannot express — each arm launches its own # probe. Skipped explicitly rather than run into a failure that says nothing # about the code, and never counted as a pass. +# These need no interferer, so they run on every family — and on the MediaTek +# they are the only automated on-air cover for the armed-window rules this +# change adds there. +run_arm retune "retune mid-window (no load needed)" +expect_all retune 0 retuned +run_arm early "read before the window elapsed (no load needed)" +expect_all early 0 not-elapsed + if [ "$realtek_sensor" != "1" ]; then echo "== SKIPPING the loaded arms: a MediaTek sensor must be brought up" echo " BEFORE the interferer. Run those arms by hand:" @@ -205,7 +251,12 @@ window_mean="$(stat_of window mean)" # construction and a 15-point bar would be asserting something untrue. sep_min=15 [ "$DUTY_ON" != "0" ] && sep_min=5 -assert_ge "load separates from floor" "$((window_mean - quiet_mean))" "$sep_min" +if [ "$window_mean" = "nan" ] || [ "$quiet_mean" = "nan" ]; then + echo "FAIL load separates from floor: an arm produced no valid samples" + fails=$((fails + 1)) +else + assert_ge "load separates from floor" "$((window_mean - quiet_mean))" "$sep_min" +fi f0="$(flood_frames)" run_arm sampled "shipped sampled read, same load" @@ -228,23 +279,33 @@ else echo "== NHM arms skipped (non-Realtek sensor has no NHM engine)" fi -run_arm retune "retune mid-window" -expect_all retune 0 retuned - if [ "$realtek_sensor" = "1" ]; then - # Realtek-only: the not-elapsed refusal exists because the CCX result - # register latches the previous window. The MediaTek timers have no such - # concept — a short arm there is simply a short, and valid, window. - run_arm early "read before the window elapsed" - expect_all early 0 not-elapsed run_arm stale "re-arm, then read before the new window elapsed" # The first read of each pair must be a real measurement and the second must # refuse. If the trigger did NOT clear the ready bit, the second read would # return the first window's latched value and look perfectly valid. - grep -E "^BUSY mode=stale-1st" "$OUT/stale.log" | grep -q "valid=1" || { + python3 -c " +import json,sys +ok = any(json.loads(l).get('mode') == 'stale-1st' and json.loads(l).get('valid') + for l in open(sys.argv[1]) if l.strip().startswith('{')) +sys.exit(0 if ok else 1) +" "$OUT/stale.log" || { echo "FAIL stale: the completed window did not read back"; fails=$((fails + 1)); } - bad=$(grep -E "^BUSY mode=stale " "$OUT/stale.log" | grep -vc "valid=0") - if [ "${bad:-1}" -eq 0 ]; then + # Count the second reads too: if the re-arm was refused the probe emits + # busy.skip and exits, leaving ZERO stale records — and "no bad records" + # would otherwise read as a pass. + read -r n_stale bad <(BusySpoil::None)); } - /* A result at or above the period is 100%, never an overflow artefact. */ + /* A result at or above the period is 100%, never an overflow artefact. + * Overshoots the period far enough to KILL the mutant: rounding means a + * small excess still lands on 100 either way, so the value has to exceed + * period by more than half a percent of it. Without the clamp 61000/60000 + * yields 102, which a uint8_t percentage reports as an out-of-range + * channel occupancy. */ { MockBb bb; ClmWindow w; w.arm(regs, 240000, 0, bb.wr()); - bb.clm_ticks = 60000; + bb.clm_ticks = 61000; bb.clm_ready = true; const ChannelBusy b = w.read(regs, 0, bb.rd()); check("saturated: 100%", b.busy_pct, 100); + check("saturated: window still its own", b.window_us, 240000); } /* --- an NHM read inside the window: JGR3 truncates it, 11AC inflates it, @@ -340,7 +346,10 @@ int main() { bb.clm_ticks = 30000; bb.clm_ready = true; (void)w.read(regs, 0, bb.rd()); - w.note_nhm_read(); /* nothing armed: must be ignored */ + /* A note with nothing armed must not reach the NEXT window. (The guard + * inside note_*() is belt-and-braces: arm() also clears the reason, so + * this asserts the observable behaviour rather than the mechanism.) */ + w.note_nhm_read(); check("unarmed note: nothing armed", w.armed() ? 1 : 0, 0); w.arm(regs, 240000, 0, bb.wr()); bb.clm_ticks = 30000; @@ -365,6 +374,21 @@ int main() { static_cast(BusySpoil::Interrupted)); } + /* An interrupted window that has ALSO not elapsed reports the interruption: + * the not-elapsed part is a symptom of the re-arm, and the caller's fix is + * the sequencing, not a longer wait. */ + { + MockBb bb; + ClmWindow w; + w.arm(regs, 240000, 0, bb.wr()); + w.note_nhm_read(); + bb.clm_ready = false; /* the re-arm restarted it, so it is not done */ + const ChannelBusy b = w.read(regs, 0, bb.rd()); + check("precedence: not-elapsed does not mask the interruption", + static_cast(b.spoil), + static_cast(BusySpoil::Interrupted)); + } + /* --- the JGR3 map: different addresses, same state machine. The map is * data, so a mix-up (reading CLM from the 11AC address on a Jaguar3) is a * silent wrong-register read, not a compile error. --- */ diff --git a/tests/channel_busy_selftest.cpp b/tests/channel_busy_selftest.cpp index a453021c..55be37a5 100644 --- a/tests/channel_busy_selftest.cpp +++ b/tests/channel_busy_selftest.cpp @@ -117,6 +117,107 @@ int main() { busy_from_ch_time(big / 4, (big / 4) * 3, 0).busy_pct, 25); } + /* --- the armed MediaTek window (busy_from_ch_time_window) --- + * + * These rules are the MediaTek half of the ArmChannelBusy contract, and the + * backend that owns the state needs a radio, so they are tested here where + * they are pure. Each refusal below is a reading that would otherwise be + * valid and wrong. */ + { + using devourer::busy_from_ch_time_window; + using devourer::BusySpoil; + using devourer::ChTimeWindow; + + { /* Unarmed: the sampled path, untouched by any of this. tx_now is + * deliberately LARGE — an unarmed reading has no baseline, so a build + * that dropped the armed check would report the lifetime TX counter as + * frames sent inside a window that was never armed. */ + ChTimeWindow w; + const ChannelBusy b = + busy_from_ch_time_window(w, 300, 700, 1000, false, 9999); + check("mt unarmed: valid", b.valid, 1); + check("mt unarmed: pct", b.busy_pct, 30); + check("mt unarmed: no own-tx claim", b.own_tx_in_window, 0); + check("mt unarmed: no own-tx count", b.own_tx_frames, 0); + } + { /* The boundary: elapsed EXACTLY the requested window is complete, not + * premature. `<` vs `<=` is a one-character mutation otherwise. */ + ChTimeWindow w; w.armed = true; w.window_us = 240000; + const ChannelBusy b = busy_from_ch_time_window(w, 600, 400, 240000, false, 0); + check("mt boundary: exactly the window is valid", b.valid, 1); + check("mt boundary: pct", b.busy_pct, 60); + } + { /* One microsecond short is premature. */ + ChTimeWindow w; w.armed = true; w.window_us = 240000; + const ChannelBusy b = busy_from_ch_time_window(w, 600, 400, 239999, false, 0); + check("mt boundary: one us short is refused", b.valid, 0); + check("mt boundary: reason", static_cast(b.spoil), + static_cast(BusySpoil::NotElapsed)); + } + { /* Armed and complete. */ + ChTimeWindow w; w.armed = true; w.window_us = 240000; + const ChannelBusy b = busy_from_ch_time_window(w, 600, 400, 250000, false, 0); + check("mt armed: valid", b.valid, 1); + check("mt armed: pct", b.busy_pct, 60); + } + { /* Read before the requested window elapsed. These timers have no ready + * bit, so without this a glance reads as a finished measurement. */ + ChTimeWindow w; w.armed = true; w.window_us = 240000; + const ChannelBusy b = busy_from_ch_time_window(w, 600, 400, 5000, false, 0); + check("mt early: refused", b.valid, 0); + check("mt early: reason", static_cast(b.spoil), + static_cast(BusySpoil::NotElapsed)); + } + { /* A retune ran through it. */ + ChTimeWindow w; w.armed = true; w.spoiled = true; w.window_us = 240000; + const ChannelBusy b = busy_from_ch_time_window(w, 600, 400, 250000, false, 0); + check("mt retuned: refused", b.valid, 0); + check("mt retuned: reason", static_cast(b.spoil), + static_cast(BusySpoil::Retuned)); + } + { /* The 1 Hz telemetry poll read-and-cleared the same registers, so these + * counts are the remainder of the window, not the window. */ + ChTimeWindow w; w.armed = true; w.window_us = 240000; + const ChannelBusy b = busy_from_ch_time_window(w, 600, 400, 250000, true, 0); + check("mt disturbed: refused", b.valid, 0); + check("mt disturbed: reason", static_cast(b.spoil), + static_cast(BusySpoil::Interrupted)); + } + { /* Interruption outranks "not elapsed", as on Realtek: the short + * interval is a symptom of the theft. */ + ChTimeWindow w; w.armed = true; w.window_us = 240000; + const ChannelBusy b = busy_from_ch_time_window(w, 600, 400, 5000, true, 0); + check("mt precedence: interrupted beats not-elapsed", + static_cast(b.spoil), + static_cast(BusySpoil::Interrupted)); + } + { /* A retune outranks a disturbed read: the earlier and larger fact. */ + ChTimeWindow w; w.armed = true; w.spoiled = true; w.window_us = 240000; + const ChannelBusy b = busy_from_ch_time_window(w, 600, 400, 250000, true, 0); + check("mt precedence: retune wins", static_cast(b.spoil), + static_cast(BusySpoil::Retuned)); + } + { /* Own transmission: these timers count it as busy, so the reading says + * so rather than being silently corrected. */ + ChTimeWindow w; w.armed = true; w.window_us = 240000; w.tx_at_arm = 1000; + const ChannelBusy b = busy_from_ch_time_window(w, 900, 100, 250000, false, 1450); + check("mt own-tx: still valid", b.valid, 1); + check("mt own-tx: flagged", b.own_tx_in_window, 1); + check("mt own-tx: count", b.own_tx_frames, 450); + } + { /* A TX counter that went backwards must not underflow. */ + ChTimeWindow w; w.armed = true; w.window_us = 240000; w.tx_at_arm = 5000; + const ChannelBusy b = busy_from_ch_time_window(w, 900, 100, 250000, false, 7); + check("mt own-tx: reset does not underflow", b.own_tx_frames, 0); + check("mt own-tx: reset leaves it unflagged", b.own_tx_in_window, 0); + } + { /* An armed window over dead counters is "no reading", never 0%. */ + ChTimeWindow w; w.armed = true; w.window_us = 240000; + const ChannelBusy b = busy_from_ch_time_window(w, 0, 0, 250000, false, 0); + check("mt armed over dead counters: no reading", b.valid, 0); + } + } + if (g_fail) { std::printf("channel_busy: %d failure(s)\n", g_fail); return 1; From f7df5237d00afd55bc0e5204bc6a18dd4fa71b28 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:40:19 +0300 Subject: [PATCH 3/4] tests: busy_window_probe builds on MSVC (std::_Exit, no unistd.h) The probe's no-destructor exit used POSIX _exit through , which MSVC has no header for (C1083 in the windows-latest job). std::_Exit from has the same semantics and is what examples/sense already uses. Co-Authored-By: Claude Fable 5.1 --- tests/busy_window_probe.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/busy_window_probe.cpp b/tests/busy_window_probe.cpp index fc46f981..e3be9ead 100644 --- a/tests/busy_window_probe.cpp +++ b/tests/busy_window_probe.cpp @@ -48,7 +48,6 @@ #include #include #include -#include #include #if __has_include() @@ -128,7 +127,8 @@ void emit(const char *mode, int i, const devourer::ChannelBusy &b, * the next run of this script refuse the adapter it just used. The libusb * context is NOT closed on that path — the kernel reclaims it at process exit, * and closing it under a live RX thread is the very hazard being avoided. The - * non-RX paths return normally and the session closes everything. */ + * non-RX paths return normally and the session closes everything. + * std::_Exit rather than POSIX _exit: same semantics, and it builds on MSVC. */ struct Cleanup { IRadio *dev = nullptr; std::shared_ptr *lock = nullptr; @@ -147,7 +147,7 @@ int finish(int code) { g_cleanup.lock->reset(); if (g_cleanup.session) g_cleanup.session->release_lock(); - _exit(code); + std::_Exit(code); } } // namespace From f61fcd4728ca3aaa2930290ccaf27a0e13b650ae Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:50:53 +0300 Subject: [PATCH 4/4] sensing: a not-elapsed read keeps the window; gates and notes the review found Review round on the busy window, with the on-air re-run on the final code (Jaguar3 8812CU, Jaguar2 8822BU, Jaguar1 8821AU; steady and bursty). - A read before the window elapsed no longer consumes it. Consuming it sent the natural retry down the sampled path, which re-arms the engine for 2 ms and reports that as a valid reading, and on the JGR3 map destroys the window still counting. On the MediaTek the refusal is decided from the host-side arm time BEFORE the timers are read, because those are read-and-clear and a read that reached them would take the counts. - with_ccx lends nothing before bring-up on all three Realtek families. - Jaguar1 notes the retune on both sides of the tune: it has no register lock spanning it, and send_packet's radiotap CHANNEL hop reaches FastRetune from the TX thread, so an arm between the note and the tune read back a valid two-channel blend. - MT7612U GetChannelBusy/ArmChannelBusy also refuse on _rx_stop: StopRxLoop clears _rx_active only after the unlocked ring teardown, and in between the MAC answered with a plausible 0%. - mt7612u_link_stats_start clears the disturbed flag its comment claimed it cleared; an armed, elapsed MediaTek window over dead timers reports a lost window rather than "no sensor". - The harness waits for the flooder to air at level before the loaded arms: a Jaguar2 txdemo does not radiate at level for ~4 s after its first submit (sensor-first measurement), and the fixed 4 s sleep put the Jaguar3's first arm on that edge, where it read a VALID 0%. - Docs and caps comments read as current state: Jaguar1 measured on both dies, the RTL8733B has a CLM engine that is not ported rather than absent. Co-Authored-By: Claude Fable 5.1 --- docs/rx-spectrum-sensing.md | 37 ++++++++++++++++---------- src/BusyWindow.h | 19 ++++++++----- src/IRadio.h | 6 +++-- src/RxSense.h | 7 +++++ src/jaguar1/RtlJaguarDevice.cpp | 38 ++++++++++++++++---------- src/jaguar1/RtlJaguarDevice.h | 4 +++ src/jaguar2/RtlJaguar2Device.h | 4 +++ src/jaguar3/RtlJaguar3Device.h | 4 +++ src/mt7612u/CLAUDE.md | 2 +- src/mt7612u/Mt7612uRadio.cpp | 47 +++++++++++++++++++++++++-------- src/mt7612u/Mt7612uRadio.h | 6 +++++ src/mt7612u/init.cpp | 1 + src/rtl8733b/Rtl8733bDevice.cpp | 9 ++++--- tests/busy_window_probe.sh | 12 ++++++++- tests/busy_window_selftest.cpp | 9 +++++++ tests/channel_busy_selftest.cpp | 3 +++ 16 files changed, 156 insertions(+), 52 deletions(-) diff --git a/docs/rx-spectrum-sensing.md b/docs/rx-spectrum-sensing.md index 5700ac79..4bc4bed9 100644 --- a/docs/rx-spectrum-sensing.md +++ b/docs/rx-spectrum-sensing.md @@ -295,12 +295,13 @@ on decoded foreign airtime plus the false-alarm term only CCA/FA/IGI/NHM-busy: whether CLM earns a place in either is a policy decision that needs its own validation, not a side effect of adding a sensor. -### In a TX session, CLM shares the FA counters' fate +### In a TX session, CLM is alive on Jaguar1/3 and inert on Jaguar2 — and biased where alive devourer's frame-free counters are known to go inert inside a transmit-oriented session on some generations, which is what blocks TX-side quiet-window sensing in `src/hopset/`. CLM was a plausible escape — it is a plain baseband tick -counter, not something riding the DIG runtime. It is not: +counter, not something riding the DIG runtime. On Jaguar2 it is not, and where +it does count it reads LOW while the sensor itself transmits: | sensor | TX session, clean | TX session, carrier present | |---|---|---| @@ -318,18 +319,17 @@ on which the counters were previously measured *inert* in a TX session with 4–20 ms quiet windows. The difference here is a 300 ms window. So window length, not generation, is the live variable in that older result. -Jaguar1 is measured now, through the armed window, and the answer is sharper -than "alive": CLM keeps counting while the adapter transmits, but reads LOW, -because it counts receive-side deferral and the receiver is deaf while the PA -is up. See "Own transmission is carried, not corrected" below for both -families' numbers — which is also why the reading carries `own_tx_in_window` -rather than an attempted correction. +On Jaguar1 (8812AU, through the armed window) CLM keeps counting while the +adapter transmits, but reads LOW, because it counts receive-side deferral and +the receiver is deaf while the PA is up. "Own transmission is carried, not +corrected" below has both families' numbers — which is why the reading carries +`own_tx_in_window` rather than an attempted correction. The generation coverage is the same as NHM's — the two ride one code path (`src/NhmReader.h`), so CLM lands wherever NHM does. Measured on Jaguar3 (8812CU, the JGR3 register map) and Jaguar2 (8822BU, the 11AC map) — so **both maps are -hardware-validated**. Jaguar1 is unmeasured but shares the 11AC map with the -validated Jaguar2. Not measured on Kestrel; on Kestrel the vendor engine computes +hardware-validated**, and Jaguar1 (8812AU, 8821AU) on the 11AC map through the +armed window below. Not measured on Kestrel; on Kestrel the vendor engine computes `clm_ratio` already and `hal/halbb/g6/kestrel_halbb_glue.c` discards it. The register addresses sit in the same dwords as the NHM ones: CLM period is the @@ -449,8 +449,7 @@ sensor silent vs transmitting: | Jaguar3 RTL8812CU | 60.9% | **18.4-18.6%** | 1503-3260 | `ChannelBusy::own_tx_in_window` and `own_tx_frames` say so; a ranker must not -mix a hot sample with a quiet one in either direction. This also corrects the -TX-session note further up: CLM is alive in a transmit session, but biased. +mix a hot sample with a quiet one in either direction. ### Saturation, and what is not measured @@ -476,8 +475,18 @@ where a result at or above the period reports 100%. MT7612U (channel timers). Under one flooder on one channel the three Realtek families and the MediaTek independently measured the same load at 61-71% — the spread is antenna and receiver gain, not a units disagreement. Kestrel and the -RTL8733B return 0 from the arm (no CCX engine is wired up on either), and their -callers keep the sampled path. +RTL8733B return 0 from the arm (the CCX engine is not wired up on either — the +RTL8733B has a working one, see the caps comment in its device source), and +their callers keep the sampled path. + +Both harness runs above used an MT7612U flooder. With a Jaguar2 (8822BU) +`txdemo` flooder the same harness read a **valid 0%** for the first ~600 ms of +the Jaguar3 sensor's first arm — not the sensor: with the sensor up first, its +armed windows stayed at 0 for ~4 s after the flooder's first submitted frame +and then stepped to 61-65%. The 8822BU does not air at level for ~4 s after +its first submit, and the harness now waits for that before the loaded arms. +Why it takes that long is an open question on the Jaguar2 TX path, not on +this sensor. ## Detecting a tone diff --git a/src/BusyWindow.h b/src/BusyWindow.h index 42c04519..5b5bf542 100644 --- a/src/BusyWindow.h +++ b/src/BusyWindow.h @@ -101,19 +101,26 @@ class ClmWindow { /* Read an armed window. An unarmed, spoiled or not-yet-elapsed window * yields an INVALID reading rather than a number: "no reading" and "quiet * channel" are different facts and every consumer downstream acts on the - * difference. */ + * difference. + * + * A completed or spoiled window is consumed by the read. A NOT-ELAPSED one + * is not: the hardware is still counting it, so the caller reads again + * when its dwell ends. Consuming it here would send that retry down the + * sampled path, which re-arms the engine for 2 ms and reports THAT as a + * valid reading — on the JGR3 map it also destroys the window still + * running. */ ChannelBusy read(const NhmRegs ®s, uint64_t tx_submitted, const Read32 &read32) { ChannelBusy b; if (!armed_) return b; - armed_ = false; /* An earlier reason wins: a window that something re-armed mid-flight is * "interrupted", and the fact that it is consequently not elapsed either * is a symptom of that, not a second finding. Checked BEFORE the ready * bit for exactly that reason. */ if (spoil_ != BusySpoil::None) { + armed_ = false; last_spoil_ = spoil_; b.spoil = spoil_; return b; @@ -123,12 +130,12 @@ class ClmWindow { if (!c.ready) { /* The ready bit is still clear: the window has not finished. The result * register holds the PREVIOUS window, so reporting it would be a stale - * reading wearing this window's timestamp. */ - spoil_ = BusySpoil::NotElapsed; - last_spoil_ = spoil_; - b.spoil = spoil_; + * reading wearing this window's timestamp. Still armed: read again. */ + last_spoil_ = BusySpoil::NotElapsed; + b.spoil = BusySpoil::NotElapsed; return b; } + armed_ = false; b.valid = true; b.source = BusySource::Clm; b.valid_busy = true; diff --git a/src/IRadio.h b/src/IRadio.h index 05977c7a..ba4b355c 100644 --- a/src/IRadio.h +++ b/src/IRadio.h @@ -617,8 +617,10 @@ class IRadio { * of the dwell. A window is spoiled by an NHM read (IRtlRadio::GetRxEnergy * with with_nhm, which re-arms the shared CCX engine), by a retune, and by * reading before it has elapsed; the reading then comes back INVALID rather - * than plausible-but-wrong. Single control thread, like every other - * control-plane entry point. */ + * than plausible-but-wrong. A spoiled or completed window is consumed by + * the read; a not-yet-elapsed one stays armed, so the caller reads again + * at the end of its dwell instead of re-arming. Single control thread, like + * every other control-plane entry point. */ virtual uint32_t ArmChannelBusy(uint32_t window_us) { (void)window_us; return 0; diff --git a/src/RxSense.h b/src/RxSense.h index 4f933b79..369994b2 100644 --- a/src/RxSense.h +++ b/src/RxSense.h @@ -313,6 +313,13 @@ inline ChannelBusy busy_from_ch_time_window(const ChTimeWindow &w, } } ChannelBusy b = busy_from_ch_time(busy, idle, interval_us); + /* An armed, elapsed, undisturbed window whose timers read nothing is a + * window that was LOST (a MAC that stopped counting), not "no sensor": + * that shape is reserved for a backend without one. */ + if (w.armed && !b.valid) { + b.spoil = BusySpoil::Interrupted; + return b; + } if (w.armed && b.valid) { const uint64_t sent = tx_now > w.tx_at_arm ? tx_now - w.tx_at_arm : 0; b.own_tx_frames = sent > UINT32_MAX ? UINT32_MAX diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index a3613e90..0b66ee7e 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -1925,11 +1925,9 @@ void RtlJaguarDevice::SetMonitorChannel(SelectedChannel channel) { * change and report the blend as one channel's occupancy. */ /* Scoped to the note, NOT held across the tune. Holding it deadlocks: when * the fast path declines, the fallback calls SetMonitorChannel(), which - * takes this same non-recursive mutex again. The note is safe scoped - * because the control plane is single-threaded by contract (see - * IRadio::ArmChannelBusy) — and on the families that DO have a register - * lock, that lock spans the tune and with_ccx takes it first, which closes - * the gap there for free. */ + * takes this same non-recursive mutex again. With no register lock spanning + * the tune on this family, the note is taken on BOTH sides of it (below), + * so an arm that lands in between is still spoiled. */ { std::lock_guard ccx(busy_window_mutex()); busy_window_note_retune(); @@ -1947,6 +1945,14 @@ void RtlJaguarDevice::SetMonitorChannel(SelectedChannel channel) { * all-paths behaviour is byte-identical when the knob is unused. */ if (_rx_path_mask >= 0) _device.rtw_write8(0x808, static_cast(_rx_path_mask.load())); + /* And after: with no register lock spanning the tune, an arm that landed + * between the note above and the channel change would otherwise read back + * a valid two-channel blend. send_packet's radiotap CHANNEL hop reaches + * FastRetune from the TX thread, so that arm is not hypothetical. */ + { + std::lock_guard ccx(busy_window_mutex()); + busy_window_note_retune(); + } } void RtlJaguarDevice::SetRxPathMask(uint8_t mask) { @@ -1963,17 +1969,19 @@ void RtlJaguarDevice::FastRetune(uint8_t channel, bool cache_rf) { * change and report the blend as one channel's occupancy. */ /* Scoped to the note, NOT held across the tune. Holding it deadlocks: when * the fast path declines, the fallback calls SetMonitorChannel(), which - * takes this same non-recursive mutex again. The note is safe scoped - * because the control plane is single-threaded by contract (see - * IRadio::ArmChannelBusy) — and on the families that DO have a register - * lock, that lock spans the tune and with_ccx takes it first, which closes - * the gap there for free. */ + * takes this same non-recursive mutex again. With no register lock spanning + * the tune on this family, the note is taken on BOTH sides of it (below), + * so an arm that lands in between is still spoiled. */ { std::lock_guard ccx(busy_window_mutex()); busy_window_note_retune(); } if (_radioManagement->fast_retune(channel, cache_rf)) { _channel.Channel = channel; + /* And after — see SetMonitorChannel. The declined path lands in + * SetMonitorChannel, which notes on both sides itself. */ + std::lock_guard ccx(busy_window_mutex()); + busy_window_note_retune(); return; } /* Fast path declined (band change / non-20MHz) — do the full channel set, @@ -1992,14 +2000,16 @@ void RtlJaguarDevice::FastSetBandwidth(ChannelWidth_t bw) { * change (Jaguar2/3), the note sits inside it and with_ccx's ordering does * the rest. Here there is no such lock, and holding this one across the * change would deadlock — the declined fast path falls back to - * SetMonitorChannel(), which takes it again — so the note is scoped and the - * single-control-thread contract carries it. */ + * SetMonitorChannel(), which takes it again — so the note is scoped and + * taken on both sides of the change instead. */ { std::lock_guard ccx(busy_window_mutex()); busy_window_note_retune(); } if (_radioManagement->fast_set_bandwidth(bw)) { _channel.ChannelWidth = bw; + std::lock_guard ccx(busy_window_mutex()); /* and after */ + busy_window_note_retune(); return; } /* Fast path declined (40/80 MHz endpoint, non-8812 die, or no clean 20 MHz @@ -2187,8 +2197,8 @@ devourer::AdapterCaps RtlJaguarDevice::GetAdapterCaps() { c.tx_chains = chains; c.rx_chains = chains; c.per_chain_rssi = chains >= 2; - /* CCX CLM via NhmReader's 11AC map, now measured on this family too - * (RTL8812AU, docs/rx-spectrum-sensing.md): a 240 ms armed window read + /* CCX CLM via NhmReader's 11AC map, measured on this family + * (RTL8812AU, RTL8821AU; docs/rx-spectrum-sensing.md): a 240 ms armed window read * 70.6-70.9% against a flooder that a MediaTek adapter independently * measured, 0.1-1.0% quiet, and it behaves like the Jaguar2 in every * window arm — period-bounded, latched, spoiled by an NHM read as a 4-point diff --git a/src/jaguar1/RtlJaguarDevice.h b/src/jaguar1/RtlJaguarDevice.h index 50d3bc8b..66a9687f 100644 --- a/src/jaguar1/RtlJaguarDevice.h +++ b/src/jaguar1/RtlJaguarDevice.h @@ -437,6 +437,10 @@ class RtlJaguarDevice : public IRtlRadio { /* This generation's CCX map and register access, under its locks — see * IRtlRadio::with_ccx. Private: the base class calls it, nobody else. */ bool with_ccx(const CcxFn &fn) override { + /* Nothing to lend before bring-up: the BB is not programmed, and a + * window armed against it would be forgotten by Init/InitWrite's reset. */ + if (!_brought_up) + return false; /* No family-wide register lock on this generation; the CCX lock is * the serialisation. */ std::lock_guard ccx(busy_window_mutex()); diff --git a/src/jaguar2/RtlJaguar2Device.h b/src/jaguar2/RtlJaguar2Device.h index 3c6fbd20..8722cbf6 100644 --- a/src/jaguar2/RtlJaguar2Device.h +++ b/src/jaguar2/RtlJaguar2Device.h @@ -211,6 +211,10 @@ class RtlJaguar2Device : public IRtlRadio { /* This generation's CCX map and register access, under its locks — see * IRtlRadio::with_ccx. Private: the base class calls it, nobody else. */ bool with_ccx(const CcxFn &fn) override { + /* Nothing to lend before bring-up: the BB is not programmed, and a + * window armed against it would be forgotten by Init/InitWrite's reset. */ + if (!_brought_up) + return false; std::lock_guard reg(_reg_mu); std::lock_guard ccx(busy_window_mutex()); const Read32 rd = [this](uint16_t a) { diff --git a/src/jaguar3/RtlJaguar3Device.h b/src/jaguar3/RtlJaguar3Device.h index f1b71c96..2dc30a4c 100644 --- a/src/jaguar3/RtlJaguar3Device.h +++ b/src/jaguar3/RtlJaguar3Device.h @@ -228,6 +228,10 @@ class RtlJaguar3Device : public IRtlRadio { /* This generation's CCX map and register access, under its locks — see * IRtlRadio::with_ccx. Private: the base class calls it, nobody else. */ bool with_ccx(const CcxFn &fn) override { + /* Nothing to lend before bring-up: the BB is not programmed, and a + * window armed against it would be forgotten by Init/InitWrite's reset. */ + if (!_brought_up) + return false; std::lock_guard reg(_reg_mu); std::lock_guard ccx(busy_window_mutex()); const Read32 rd = [this](uint16_t a) { diff --git a/src/mt7612u/CLAUDE.md b/src/mt7612u/CLAUDE.md index 42f26a00..254e366d 100644 --- a/src/mt7612u/CLAUDE.md +++ b/src/mt7612u/CLAUDE.md @@ -20,7 +20,7 @@ candidate counter, `MT_RX_STAT_1`'s false-CCA field, is owned by **Validated on hardware** (`docs/rx-spectrum-sensing.md`): 64.0-64.3% under a flooder two Realtek generations put at 61-63%, 0.0% on a quiet channel, and 8.1-9.5% per second on a ~9%-duty bursty interferer — the arm the Realtek 2 ms -sample fails. `busy_airtime_measured` is now true. +sample fails. `busy_airtime_measured` is true. Two facts that measurement added. First, the reading **requires a running receiver**: with RX down the idle timer still advances, so busy+idle is diff --git a/src/mt7612u/Mt7612uRadio.cpp b/src/mt7612u/Mt7612uRadio.cpp index 6ad8e858..23ba19be 100644 --- a/src/mt7612u/Mt7612uRadio.cpp +++ b/src/mt7612u/Mt7612uRadio.cpp @@ -871,16 +871,35 @@ devourer::ChannelBusy Mt7612uRadio::GetChannelBusy() { * landing in that gap found nothing to spoil and the post-retune remainder * came back valid. */ std::lock_guard lock(_mu); - /* Re-checked UNDER the lock: StopRxLoop can finish between the cheap check - * above and here, and the C layer would still answer (ch_time_armed stays - * set), handing back a plausible reading for a receiver that is down — the - * fabricated zero this gate exists to prevent. */ - if (!_dev || !_rx_active.load(std::memory_order_acquire)) + /* Re-checked UNDER the lock, and on _rx_stop as well as _rx_active: + * StopRxLoop raises _rx_stop before it quiesces the MAC and clears + * _rx_active only after the unlocked ring teardown (up to ~2 s later), so + * in between the C layer still answers (ch_time_armed stays set) with the + * idle timer advancing and the busy one dead — a plausible 0% for a + * receiver that is down, the fabricated zero this gate exists to prevent. */ + if (!_dev || !_rx_active.load(std::memory_order_acquire) || _rx_stop.load()) return {}; const devourer::ChTimeWindow window = _busy; - if (_busy.armed) - _busy = devourer::ChTimeWindow{}; /* consumed, whatever the verdict */ + /* A premature read is refused BEFORE the timers are touched and the window + * stays armed, as on Realtek (devourer::ClmWindow::read): the caller reads + * again when its dwell ends. Unlike CLM these timers are read-and-clear, so + * the elapsed check cannot come from the register read itself — reaching + * it would take the counts the window is still accumulating. The host-side + * arm time is the clock. A retune outranks it, as everywhere. */ + if (window.armed && !window.spoiled) { + const auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - _busy_armed_at) + .count(); + if (elapsed >= 0 && static_cast(elapsed) < window.window_us) { + devourer::ChannelBusy early; + early.spoil = devourer::BusySpoil::NotElapsed; + return early; + } + } + /* From here every verdict consumes the window: the read below clears the + * timers, so there is nothing left to come back for. */ + auto consume = [this] { _busy = devourer::ChTimeWindow{}; }; uint32_t busy = 0, idle = 0, interval_us = 0; if (mt7612u_ch_time(_dev, &busy, &idle, &interval_us) != 0) { @@ -888,8 +907,10 @@ devourer::ChannelBusy Mt7612uRadio::GetChannelBusy() { * rather than returning the bare "this backend has no sensor" answer a * default-constructed reading means. */ devourer::ChannelBusy failed; - if (window.armed) + if (window.armed) { + consume(); failed.spoil = devourer::BusySpoil::Interrupted; + } return failed; } /* Only a C-API caller that also polls mt7612u_link_stats() can steal these @@ -900,9 +921,12 @@ devourer::ChannelBusy Mt7612uRadio::GetChannelBusy() { /* The rules themselves are pure and live next to the conversion they * qualify (devourer::busy_from_ch_time_window), so the refusals are * reachable from a selftest even though this backend needs a radio. */ - return devourer::busy_from_ch_time_window( + const devourer::ChannelBusy b = devourer::busy_from_ch_time_window( window, busy, idle, interval_us, disturbed, _tx_submitted.load(std::memory_order_relaxed)); + if (window.armed) + consume(); + return b; } /* IRadio::ArmChannelBusy on MediaTek: the channel timers already integrate @@ -928,7 +952,7 @@ uint32_t Mt7612uRadio::ArmChannelBusy(uint32_t window_us) { return 0; std::lock_guard lock(_mu); /* Checked under the lock for the same reason the read is. */ - if (!_dev || !_rx_active.load(std::memory_order_acquire)) + if (!_dev || !_rx_active.load(std::memory_order_acquire) || _rx_stop.load()) return 0; if (mt7612u_ch_time_arm(_dev) != 0) return 0; @@ -936,6 +960,7 @@ uint32_t Mt7612uRadio::ArmChannelBusy(uint32_t window_us) { _busy.armed = true; _busy.window_us = window_us; _busy.tx_at_arm = _tx_submitted.load(std::memory_order_relaxed); + _busy_armed_at = std::chrono::steady_clock::now(); return window_us; } @@ -1187,7 +1212,7 @@ devourer::AdapterCaps Mt7612uRadio::GetAdapterCaps() { c.ldpc_rx_flag = true; /* the RXWI carries the per-frame LDPC bit */ c.per_chain_rssi = true; /* Busy airtime from the MAC channel timers (MT_CH_BUSY / MT_CH_IDLE), via - * mt7612u_ch_time(). Now measured on air: 64.0-64.3% under a flooder two + * mt7612u_ch_time(). Measured on air: 64.0-64.3% under a flooder two * Realtek generations put at 61-63%, 0.0% quiet, 8.1-9.5% per second on a * ~9%-duty bursty interferer (docs/rx-spectrum-sensing.md). The reading * needs a running receiver — see GetChannelBusy. There are no phydm diff --git a/src/mt7612u/Mt7612uRadio.h b/src/mt7612u/Mt7612uRadio.h index e76c531b..ceb67021 100644 --- a/src/mt7612u/Mt7612uRadio.h +++ b/src/mt7612u/Mt7612uRadio.h @@ -2,6 +2,7 @@ #define MT7612U_RADIO_H #include +#include #include #include #include @@ -156,6 +157,11 @@ class Mt7612uRadio : public IRadio { * mark), and the TX baseline because these timers count own transmission as * busy. */ devourer::ChTimeWindow _busy; + /* When _busy was armed (steady clock). The refusal of a premature read is + * decided from THIS, before the timers are touched: they are read-and-clear, + * so a read that reached them would take the counts the window is still + * accumulating. */ + std::chrono::steady_clock::time_point _busy_armed_at; std::atomic _rx_frames{0}; /* Frames cross from the C library's event thread to the StartRxLoop thread diff --git a/src/mt7612u/init.cpp b/src/mt7612u/init.cpp index a5b8a5d0..48b57cb4 100644 --- a/src/mt7612u/init.cpp +++ b/src/mt7612u/init.cpp @@ -602,6 +602,7 @@ int mt7612u_link_stats_start(struct mt7612u_dev *d) /* One read to clear everything, so the first real sample is clean. Its * own disturbance flag is cleared below: arming is not a theft. */ mt7612u_link_stats(d, &discard); + d->ch_time_disturbed = 0; /* Arming is what makes a ch_time reading meaningful, so the interval * STARTS here — not at whatever the previous session left behind, and not * at "unknown". Stamping now rather than zeroing is what lets the first diff --git a/src/rtl8733b/Rtl8733bDevice.cpp b/src/rtl8733b/Rtl8733bDevice.cpp index e97b0db0..0c4b0943 100644 --- a/src/rtl8733b/Rtl8733bDevice.cpp +++ b/src/rtl8733b/Rtl8733bDevice.cpp @@ -1041,9 +1041,12 @@ devourer::AdapterCaps Rtl8733bDevice::GetAdapterCaps() { caps.marketing_names = "RTL8731BU/RTL8733BU"; caps.chip_id = rtl8733b::kChipId; caps.generation = devourer::ChipGeneration::Rtl8733b; - /* No frame-free sensing of any kind: no phydm FA/CCA block, no CCX/NHM - * register set for the HALMAC 87xx, and GetRxEnergy is not overridden — so - * both report false rather than letting the IRtlRadio cast imply a sensor. */ + /* No frame-free sensing ported: the phydm FA/CCA block and the CCX engine + * are not wired up here, and GetRxEnergy is not overridden — so both report + * false rather than letting the IRtlRadio cast imply a sensor. The die does + * have a working CLM engine on the JGR3 map (62-63% under a ~63% load, + * 0.0% quiet, one unit); its FA/IGI registers are unmeasured, so the port is + * its own change. */ caps.busy_airtime_ok = false; caps.rx_energy_ok = false; caps.variant = "cut-selected"; diff --git a/tests/busy_window_probe.sh b/tests/busy_window_probe.sh index d7d7e57f..09e9c443 100755 --- a/tests/busy_window_probe.sh +++ b/tests/busy_window_probe.sh @@ -68,7 +68,17 @@ start_flood() { env DEVOURER_VID="$FLOOD_VID" DEVOURER_PID="$FLOOD_PID" \ DEVOURER_CHANNEL="$CHANNEL" DEVOURER_TX_RATE=MCS1 DEVOURER_TX_GAP_US=0 \ "${burst[@]}" "$TXDEMO" > "$OUT/flood.log" 2>&1 & - sleep 4 + # Wait for the first frame OUT, then for the flooder to be airing at level. + # A Jaguar2 (8822BU) txdemo does not radiate at level for ~4 s after its + # first submit (measured with a Jaguar3 sensor armed first: 50 ms windows + # at 0% for ~60 windows after the first bulk_send, then 61-65%). A fixed + # 4 s sleep put the Jaguar3's first arm exactly on that edge and it read a + # VALID 0% — which the assertions below cannot tell from a quiet channel. + for _ in $(seq 1 100); do + grep -qE "tx\.(frame|stats)|bulk_send" "$OUT/flood.log" && break + sleep 0.2 + done + sleep "${FLOOD_SETTLE_S:-6}" grep -qE "tx\.(frame|stats)|bulk_send" "$OUT/flood.log" || { echo "WARN: flooder produced no frames — every 'under load' arm below is" echo " really a quiet-channel run and proves nothing." diff --git a/tests/busy_window_selftest.cpp b/tests/busy_window_selftest.cpp index d79e5763..25c3d2f4 100644 --- a/tests/busy_window_selftest.cpp +++ b/tests/busy_window_selftest.cpp @@ -233,6 +233,15 @@ int main() { check("early: no reading", b.valid, 0); check("early: reason reported", static_cast(w.last_spoil()), static_cast(BusySpoil::NotElapsed)); + /* The window is still counting, so it is still armed: the read at the + * end of the dwell is the measurement. A build that consumed it here + * would send that read down the sampled path instead. */ + check("early: window still armed", w.armed() ? 1 : 0, 1); + bb.clm_ready = true; + const ChannelBusy later = w.read(regs, 0, bb.rd()); + check("early: later read is the window", later.valid, 1); + check("early: later value", later.busy_pct, 63); + check("early: consumed by the completed read", w.armed() ? 1 : 0, 0); } /* --- reading without arming is not a quiet channel --- */ diff --git a/tests/channel_busy_selftest.cpp b/tests/channel_busy_selftest.cpp index 55be37a5..c412ac95 100644 --- a/tests/channel_busy_selftest.cpp +++ b/tests/channel_busy_selftest.cpp @@ -215,6 +215,9 @@ int main() { ChTimeWindow w; w.armed = true; w.window_us = 240000; const ChannelBusy b = busy_from_ch_time_window(w, 0, 0, 250000, false, 0); check("mt armed over dead counters: no reading", b.valid, 0); + check("mt armed over dead counters: reported as a lost window", + static_cast(b.spoil), + static_cast(BusySpoil::Interrupted)); } }