diff --git a/CLAUDE.md b/CLAUDE.md index 2bd0db4c..a1592d5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -461,7 +461,14 @@ 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: 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 harness: `docs/rx-spectrum-sensing.md`. diff --git a/CMakeLists.txt b/CMakeLists.txt index 348b79ca..9c1fee99 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -843,6 +843,16 @@ add_executable(CcaGatesProbe 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, +# 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) + # 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 @@ -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..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 | |---|---|---| @@ -316,14 +317,19 @@ 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. + +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 @@ -355,6 +361,133 @@ 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 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 +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. + +### 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 (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 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..5b5bf542 --- /dev/null +++ b/src/BusyWindow.h @@ -0,0 +1,186 @@ +/* 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. + * + * 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; + + /* 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; + } + + 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. 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; + 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..ba4b355c 100644 --- a/src/IRadio.h +++ b/src/IRadio.h @@ -593,6 +593,39 @@ 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. 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; + } + /* --- 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..3bfe016d 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. 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; + 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..369994b2 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 @@ -217,6 +261,75 @@ 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); + /* 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 + : static_cast(sent); + b.own_tx_in_window = sent > 0; + } + return b; +} + + } /* namespace devourer */ #endif /* RX_SENSE_H */ 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..0b66ee7e 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,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. */ + /* 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. 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(); + } /* 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 @@ -1911,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) { @@ -1923,8 +1965,23 @@ 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. */ + /* 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. 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, @@ -1935,8 +1992,24 @@ 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. + * + * 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 + * 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 @@ -2124,10 +2197,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, 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 + * 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..66a9687f 100644 --- a/src/jaguar1/RtlJaguarDevice.h +++ b/src/jaguar1/RtlJaguarDevice.h @@ -434,6 +434,26 @@ 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 { + /* 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()); + 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..bf4f0284 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,16 @@ 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); + /* 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; /* 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 +936,15 @@ 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 */ /* Serialize against the thermal-track tick's RF-window read. */ std::lock_guard lk(_reg_mu); + /* 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); if (_hal.fast_retune(channel, static_cast(_channel.ChannelWidth), _channel.ChannelOffset, cache_rf)) { @@ -940,6 +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. 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; @@ -976,7 +1012,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 +1025,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..8722cbf6 100644 --- a/src/jaguar2/RtlJaguar2Device.h +++ b/src/jaguar2/RtlJaguar2Device.h @@ -208,6 +208,25 @@ 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 { + /* 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) { + 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..b4b42f64 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,21 @@ 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); + /* 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 ch_changed = channel.Channel != _channel.Channel; _channel = channel; _radioManagement.set_channel_bwmode(channel.Channel, channel.ChannelOffset, @@ -1379,7 +1409,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 +1440,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..2dc30a4c 100644 --- a/src/jaguar3/RtlJaguar3Device.h +++ b/src/jaguar3/RtlJaguar3Device.h @@ -225,6 +225,25 @@ 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 { + /* 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) { + 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..254e366d 100644 --- a/src/mt7612u/CLAUDE.md +++ b/src/mt7612u/CLAUDE.md @@ -11,14 +11,36 @@ 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 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 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. + +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..23ba19be 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(); @@ -413,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) { @@ -459,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(); @@ -578,8 +595,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,26 +850,118 @@ 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() { + if (!_rx_active.load(std::memory_order_acquire)) + 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, 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; + /* 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; - { - /* 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 {}; + 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) { + consume(); + 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. */ + 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 + * over the whole interval between reads, so "arming" is resetting them and + * 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 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) { + /* 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); + /* Checked under the lock for the same reason the read is. */ + if (!_dev || !_rx_active.load(std::memory_order_acquire) || _rx_stop.load()) + return 0; + if (mt7612u_ch_time_arm(_dev) != 0) + return 0; + _busy = devourer::ChTimeWindow{}; + _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; } devourer::TxStats Mt7612uRadio::GetTxStats() { @@ -1099,11 +1212,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(). 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..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 @@ -99,6 +100,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 +150,18 @@ class Mt7612uRadio : public IRadio { std::mutex _teardown_mu; std::atomic _rx_stop{false}; std::atomic _rx_active{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; + /* 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/include/mt7612u/mt7612u.h b/src/mt7612u/include/mt7612u/mt7612u.h index e3bacb86..9a393ccb 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); @@ -449,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 ff4d55f6..48b57cb4 100644 --- a/src/mt7612u/init.cpp +++ b/src/mt7612u/init.cpp @@ -599,16 +599,63 @@ 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. Reset the mark with - * it: the next interval must start here, not at whatever the previous - * session left behind. */ + 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 + * 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_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) @@ -650,6 +697,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/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.cpp b/tests/busy_window_probe.cpp new file mode 100644 index 00000000..e3be9ead --- /dev/null +++ b/tests/busy_window_probe.cpp @@ -0,0 +1,373 @@ +/* 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 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 + * 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 + +#if __has_include() +#include +#else +#include +#endif + +#include "AdapterCaps.h" +#include "Event.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"; + } +} + +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) { + 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. + * 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. 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. + * std::_Exit rather than POSIX _exit: same semantics, and it builds on MSVC. */ +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(); + std::_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; + } + } + + g_ev.configure(stdout); + 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); + /* 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); + 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(); + 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, + .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) { + 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. */ + 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([&]() { + /* 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)) { + try { + if (rtl) + (void)rtl->GetRxQuality(); + } catch (const std::exception &) { + } + } + }); + 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) { + devourer::Ev(g_ev, "busy.skip").t().f("why", "second arm refused"); + return finish(5); + } + nap(5); + } else if (mode == "txsess") { + /* 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()); + 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..09e9c443 --- /dev/null +++ b/tests/busy_window_probe.sh @@ -0,0 +1,373 @@ +#!/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 +# (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 +# 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 & + # 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." + 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 + 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 + 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 +} + +# 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 '"ev":"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=$(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 + 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=$(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)); } + 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. +# 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 +[ "$(printf '%d' "$SENSOR_VID")" -eq "$(printf '%d' 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. +# 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:" + 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 +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" +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 + +if [ "$realtek_sensor" = "1" ]; then + 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. + 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)); } + # 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 < +#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. + * 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 = 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, + * 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)); + /* 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 --- */ + { + 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()); + /* 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; + 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)); + } + + /* 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. --- */ + { + 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; +} diff --git a/tests/channel_busy_selftest.cpp b/tests/channel_busy_selftest.cpp index a453021c..c412ac95 100644 --- a/tests/channel_busy_selftest.cpp +++ b/tests/channel_busy_selftest.cpp @@ -117,6 +117,110 @@ 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); + check("mt armed over dead counters: reported as a lost window", + static_cast(b.spoil), + static_cast(BusySpoil::Interrupted)); + } + } + if (g_fail) { std::printf("channel_busy: %d failure(s)\n", g_fail); return 1;