Skip to content

sensing: a vendor-neutral channel-busy contract, and the survey dwell as a library primitive - #432

Merged
josephnef merged 3 commits into
masterfrom
feat/neutral-channel-busy
Sep 17, 2026
Merged

josephnef merged 3 commits into
masterfrom
feat/neutral-channel-busy

Conversation

@josephnef

@josephnef josephnef commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Why

Analysing OpenIPC/PixelPilot_rk#148 — a GS spectrum screen built on the vendor driver's chan_info procfs — sent us looking for the equivalent gaps here. Three turned out to be structural and connected:

  1. Frame-free sensing is Realtek-locked. GetRxEnergy is on IRtlRadio, so src/chanmig/ and src/hopset/ — the whole adaptive-link story — are vendor-neutral in their logic but can gather no evidence at all on the MediaTek backend shipped in mt7612u: the IRadio backend — devourer opens, receives and transmits on a MediaTek adapter #422. Commit 90e1cad (Rename the radio contract to vendor-neutral names (IRadio, ITransport, CreateRadio) and split the Realtek-only members onto IRtlRadio #415) named this exact follow-up: "hopset TX-side sensing and the chanmig energy probe are Realtek-only until a neutral frame-free energy type exists".
  2. RxQuality never got rx: CLM busy-airtime and the NHM noise-floor reduction #431's new sensors. clm/nhm_env went onto RxEnergy (Realtek-only); the neutral struct that already carries fa_ofdm/cca_ofdm/igi did not.
  3. No library-level survey API. The dwell loop lived only in examples/chanscout/main.cpp — and was untested: the scheduler has a selftest, the consumer has one, the executor between them had none.

The neutral concept is busy airtime, not phydm counters

RxEnergy cannot be the portable type — it is phydm-shaped, and IRadio.h and IRtlRadio.h both assign those counters to the Realtek level on purpose. But one field in it is already neutral and says so: CLM is "AIRTIME, directly comparable across channels and adapters, and unlike NHM it needs no gain reference."

Both silicon families count it in hardware:

family mechanism
Realtek J1/J2/J3 CCX CLM, 4 µs busy ticks
MediaTek MT7612U MT_CH_BUSY / MT_CH_IDLE, already implemented and armed in this port

Commit 1 — ChannelBusy on IRadio

ChannelBusy + BusySource in src/RxSense.h, with two pure conversions so every arithmetic decision is testable with no device.

BusySource is load-bearing, not metadata: the MediaTek timers count TX+RX+NAV+EIFS, so a transmitting radio includes its own airtime, while Realtek's CLM is receive-side deferral only. Ranking across a mixed adapter pair compares two rulers.

Implemented once on IRtlRadio in terms of GetRxEnergy(true) — all five Realtek backends inherit it with no per-backend edits, and the two without CLM (RTL8733B, Kestrel) report no reading rather than a fabricated zero, by construction.

AdapterCaps::busy_airtime_ok / _measured / rx_energy_ok retire a discriminator that was never correct: the RTL8733B passes dynamic_cast<IRtlRadio*> and implements no energy reader at all.

The MediaTek side goes through a narrow new mt7612u_ch_time() touching only the two channel-timer registers. Not mt7612u_link_stats(): that also reads MT_RX_STAT_1, whose false-CCA field is read-and-clear and owned by mt7612u_phy_tick()'s AGC loop, so polling it at caller cadence would both misreport the figure and starve the gain tracking. For the same reason energy_pct is left invalid there — the only candidate counter has an owner.

Also: the Realtek DIG rails hardcoded inside neutral build_rx_quality move into LinkHealthThresholds with the same defaults. Behaviour-identical; a neutral header stops asserting Realtek register constants.

Commit 2 — src/sensing/, the dwell as a library primitive

A new subtree, the one that calls device methods. It owns no thread, performs no sleep and takes no clock of record — which is what lets chanmig/ and hopset/ keep the purity they both assert. (src/cell/ was not a precedent: it takes raw scalars and includes no IRadio.h.)

Two layers, so a later examples/tx conversion is mechanical: SenseWindow.h is the shared settle → barrier → observe → read discipline with no retune and no frame term — hopset_sense_window still carries its own copy and its comment already says "the discipline is chanscout's" — and DwellExecutor.h is the survey-shaped layer. chanscout converts onto it: 40 insertions, 186 deletions, with all the demo policy (health, thermal, p95, bin-age, advise) deliberately left behind.

A latent bug the new test caught

A SetMonitorChannel throw part-way through a full-width dwell left the width-restoration latch clear, so the next bin dwell took the lean same-width FastRetune at a possibly-80 MHz width — and every later bin would have observed at the candidate's width. A wrong reading that still looks entirely plausible. Now any full-gate tune that throws sets the latch, because the chip may have been part-way reconfigured.

Validation

Headless: 66/66 ctest including two new gates; ASan+UBSan clean; TSan clean — newly meaningful, because the frame aggregator is exercised by a test for the first time; jaguar1-only, jaguar3-only and mt7612u-only subsets build and pass. The mt7612u subset is what caught a caps block landing in GetTxCaps instead of GetAdapterCaps, since MT7612U is off in a default build.

On air (8812CU): a baseline chanscout built from master, run back to back against the refactored one on the same plan — 314 dwells each side, seq gapless on both, identical flag histogram, no field lost, observe_ms median 112 → 112 (0% drift), retune_us 1304 → 1314 (1%). An earlier run showed 107 → 102, which is what exposed finish() sampling its timestamp before the observation read and silently shortening the window the plausibility ceiling derives from. tests/chanscout_stress.sh completed its full run: 2728 consecutive dwell retunes, seq gapless, zero flags of any kind set across the whole stream (no truncation, no retune failure, no read failure, no counter-suspect, no missing NHM), scout health ok throughout, no wedge.

Not validated, and shipped saying so: the MediaTek path — no MT7612U on the bench. busy_airtime_measured = false, register basis documented in src/mt7612u/CLAUDE.md. Two things to measure when an adapter is available: that busy/idle track real occupancy beyond repetition noise, and — the real risk — that polling at dwell cadence does not disturb phy_tick's gain tracking.

Deliberately not here

  • Rewiring ChannelScore off its fa_rate/(fa_rate+200) magic constant onto real CLM airtime. That changes the migration law and needs its own re-validation of the 14-row failure matrix and an on-air soak.
  • Converting examples/tx. It is the TX hot path, called inside the slot-timed hop loop, and its correctness criterion is on-air FHSS lockstep that headless tests cannot see.
  • Nothing calls GetChannelBusy yet, so commit 1 is provably no-behaviour-change on every Realtek path.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Au9D2ntoFn4ABLJqp9vYDk

josephnef and others added 2 commits September 17, 2026 15:09
…adio

## Problem

`GetRxEnergy` is declared on `IRtlRadio`, so `src/chanmig/` and `src/hopset/`
— the adaptive-link subsystems — are vendor-neutral in their logic but can
gather no evidence at all on the MediaTek backend. Commit 90e1cad (#415) named
this follow-up: "hopset TX-side sensing and the chanmig energy probe are
Realtek-only until a neutral frame-free energy type exists".

`RxEnergy` cannot be that type. It is phydm-shaped — false-alarm classes, the
DIG index, IGI-referenced NHM buckets — and IRadio.h and IRtlRadio.h both
assign those to the Realtek level on purpose.

But one field in it is already neutral, and says so: CLM is "AIRTIME, directly
comparable across channels and adapters, and unlike NHM it needs no gain
reference". Both silicon families count busy airtime in hardware — Realtek via
CCX CLM, MediaTek via MT_CH_BUSY/MT_CH_IDLE with TX+RX+NAV+EIFS counted busy.

## Change

- `ChannelBusy` + `BusySource` in `src/RxSense.h`, with two pure conversions
  (`busy_from_rx_energy`, `busy_from_ch_time`) so every arithmetic decision is
  testable without hardware. `BusySource` is part of the reading, not metadata:
  the MediaTek timers count a transmitting radio's own airtime and Realtek's
  CLM does not, so ranking across a mixed adapter pair compares two rulers.
- `IRadio::GetChannelBusy()`, optional virtual with the house all-invalid
  default.
- Implemented ONCE on `IRtlRadio` in terms of `GetRxEnergy(true)`. All five
  Realtek backends inherit it with no per-backend edits, and the two without
  CLM — the RTL8733B (no override at all) and Kestrel (NHM rides the halbb
  glue, not NhmReader) — report no reading rather than a fabricated zero.
- `Mt7612uRadio::GetChannelBusy()` from a new narrow `mt7612u_ch_time()`.
- `AdapterCaps::busy_airtime_ok` / `_measured` / `rx_energy_ok`, set honestly
  per family.
- `RxQuality` carries the neutral reading. Deliberately NOT fed into
  `LinkHealthInput`: whether CLM belongs in a scoring law needs its own
  validation, and routing it through `classify_link_health` would change every
  backend's verdict as a side effect of an interface change.
- The Realtek DIG rails hardcoded inside neutral `build_rx_quality`
  (`igi_min = 0x1c; igi_max = 0x7f`) move into `LinkHealthThresholds` with the
  same defaults — behaviour-identical, and a neutral header stops asserting
  Realtek register constants.

`mt7612u_ch_time()` reads ONLY the two channel-timer registers. It is not
`mt7612u_link_stats()` because that also reads MT_RX_STAT_1, whose false-CCA
field is read-and-clear and owned by `mt7612u_phy_tick()`'s AGC loop; polling
it at caller cadence would both misreport the figure and starve the gain
tracking. For the same reason `energy_pct` is left invalid on MediaTek — the
only candidate counter has an owner. Reads are checked, because `mt_rr`'s `~0u`
failure value would otherwise surface as a 100%-busy channel.

## Validation

New `channel_busy_math` ctest covers both conversions, including the case that
matters most — `busy + idle == 0` reports NO reading, never 0% busy, because a
never-armed counter is not an idle channel. Also a 64-bit ratio near UINT32_MAX
that wraps to nonsense in 32 bits.

`radio_iface_selftest` gains three things: the neutral default and caps
defaults on a radio with no Realtek type in sight (that it compiles is the
proof of placement); the RTL8733B shape, where the inherited implementation
must fabricate nothing; and a third fixture overriding only `GetRxEnergy`,
proving the once-on-IRtlRadio implementation reaches a derived reader. Its
existing cast assertion is untouched.

65/65 ctest, plus the mt7612u-only subset — which is what caught a caps block
landing in GetTxCaps instead of GetAdapterCaps, since MT7612U is off in a
default build.

No behaviour change on any Realtek path: nothing calls the new member yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Au9D2ntoFn4ABLJqp9vYDk
## Problem

The loop that turns a channel plan into evidence lived only in
`examples/chanscout/main.cpp`. Two consequences:

An integrator wanting to survey the band had to re-implement it from a demo —
which is why that kind of feature gets built against the kernel driver's
procfs instead.

And it was untested. `scan_plan_policy` covers the scheduler,
`survey_aggregate` covers the consumer, and the retune/barrier/observe
sequencing between them had nothing: a regression in it was findable only on
air.

## Change

New `src/sensing/`, the one helper subtree that calls device methods. It owns
no thread, performs no sleep and takes no clock of record — which is precisely
what lets `src/chanmig/` and `src/hopset/` keep the purity they both assert.
`src/cell/` was not a precedent for this: it takes raw scalars and includes no
`IRadio.h`.

Two layers, because the split is what makes a later `examples/tx` conversion
mechanical rather than a redesign:

- `SenseWindow.h` — the shared settle -> DISCARD BARRIER -> observe -> read
  discipline, in microseconds, with no retune and no frame term. A null
  `IRtlRadio*` is a first-class case. `examples/tx`'s `hopset_sense_window`
  still carries its own copy (its comment says "the discipline is
  chanscout's"); this header is shaped for it, and it gains the
  counter-plausibility check it owns a flag for but never sets.
- `DwellExecutor.h` — the survey-shaped layer: three phases the caller drives,
  because the two sleeps are policy (chanscout wants a chunked stop-aware nap
  so SIGINT stays responsive) and a library that slept would have to pick one.
- `SurveyFrameAgg.h` — the frame fold as a library type, with the
  ours-vs-foreign attribution key as CONFIGURATION. A library hardcoding the
  canonical devourer SA would score an integrator's own video as interference.

`examples/chanscout` converts onto it: 40 insertions, 186 deletions. What
deliberately did NOT move is the demo policy — health reporting, the retune-p95
USB check, the Jaguar1 thermal probe, bin-age staleness, advise mode. If those
migrated this would stop being an extraction.

## A latent bug the new test caught

A `SetMonitorChannel` throw part-way through a full-width dwell left the
width-restoration latch clear, so the next bin dwell took the lean
same-width `FastRetune` at a possibly-80 MHz width — and every later bin would
have observed at the candidate's width, a wrong reading that still looks
entirely plausible. The latch is now set whenever a full-gate tune throws,
because the chip may have been part-way reconfigured and its width is unknown.
A `FastRetune` throw is left alone: that path never changes width.

`observe_ms` is timed inside `finish()` rather than from a caller-supplied
`now`. The argument form would be evaluated before the observation read and
would silently exclude it, shortening the very window the plausibility ceiling
is computed from — measured as a 107 -> 102 ms shift before this was fixed.

## Validation

New `dwell_executor` ctest against a scripted fake radio and a scripted clock,
so no duration is timing-dependent: barrier ordering (frames folded before or
during the barrier are absent, frames inside the window are present), the
width-restoration rule and both its throw paths, counter plausibility at the
exact ceiling (`>` not `>=`), the read-failure latch (never-valid is unported,
not failed), frame attribution flipping with the configured SA, and round/seq
bookkeeping against the real `ScanScheduler`. Plus the case this whole line of
work exists for: a plain `IRadio` with no Realtek type still produces usable
dwells.

66/66 ctest; ASan+UBSan clean; TSan clean, newly meaningful because the frame
aggregator is exercised by a test for the first time; jaguar1-only,
jaguar3-only and mt7612u-only subsets build and pass.

On air, 8812CU, baseline binary built from master and run back to back against
the refactored one on the same plan: 314 dwells each side, seq gapless on both,
identical flag histogram, no field lost, `observe_ms` median 112 -> 112 (0%
drift), `retune_us` 1304 -> 1314 (1%). `tests/chanscout_stress.sh` ran past
1100 consecutive dwell retunes with no wedge and no errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Au9D2ntoFn4ABLJqp9vYDk
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add vendor-neutral channel sensing and reusable survey dwells

✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Adds vendor-neutral busy-airtime sensing across Realtek and MediaTek radios.
• Extracts survey dwell sequencing and frame aggregation into a tested library.
• Fixes width restoration after failed full-width retunes and documents validation status.
Diagram

graph TD
  CS["chanscout"] --> DE["Dwell Executor"] --> API["IRadio API"] --> RTL["Realtek CLM"]
  API --> MT["MediaTek Timers"]
  AGG["Frame Aggregator"] --> DE
  DE --> SW["Sense Window"] --> RTL
  DE --> REC["Survey Dwell"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Expand RxEnergy into a cross-vendor type
  • ➕ Avoids introducing a second frame-free sensing structure.
  • ➕ Could preserve existing Realtek consumer call patterns.
  • ➖ Leaks phydm-specific FA, DIG, IGI, and NHM semantics into neutral APIs.
  • ➖ Encourages fabricated or misleading equivalents on non-Realtek hardware.
  • ➖ Cannot accurately represent differing busy-airtime provenance.
2. Keep dwell execution inside chanscout
  • ➕ Avoids a new device-touching library subtree.
  • ➕ Keeps demo-specific behavior in one source file.
  • ➖ Leaves acquisition sequencing unavailable to other consumers.
  • ➖ Prevents deterministic unit testing of retune and barrier behavior.
  • ➖ Continues duplicating the sensing discipline in future integrations.
3. Move the executor into chanmig
  • ➕ Places survey scheduling and execution under one subtree.
  • ➕ Reduces the number of top-level modules.
  • ➖ Breaks chanmig's device-independent, pure design contract.
  • ➖ Introduces hardware and IRadio dependencies into policy code.
  • ➖ Makes headless migration testing harder to isolate.

Recommendation: Retain the PR's approach. A small provenance-aware ChannelBusy reduction preserves vendor boundaries, while the dedicated sensing layer centralizes inherently impure device sequencing without contaminating chanmig or hopset. Keeping sleep and thread ownership with callers also supports different operational policies and deterministic tests.

Files changed (30) +1759 / -193

Enhancement (19) +909 / -4
AdapterCaps.hExpose frame-free sensing capabilities +22/-0

Expose frame-free sensing capabilities

• Adds explicit flags for implemented busy airtime, hardware-validated busy airtime, and Realtek energy-counter support.

src/AdapterCaps.h

IRadio.hAdd the neutral channel-busy API +23/-0

Add the neutral channel-busy API

• Introduces GetChannelBusy on the vendor-neutral radio interface with an invalid default and documented delta semantics.

src/IRadio.h

IRtlRadio.hImplement ChannelBusy once for Realtek radios +29/-1

Implement ChannelBusy once for Realtek radios

• Reduces each backend's GetRxEnergy result into ChannelBusy through the shared Realtek base class. Backends without CLM naturally return no reading.

src/IRtlRadio.h

RxQuality.hCarry neutral busy evidence in RxQuality +14/-2

Carry neutral busy evidence in RxQuality

• Adds ChannelBusy to RxQuality and derives it from the existing RxEnergy read without consuming counters twice. Existing health scoring remains unchanged.

src/RxQuality.h

RxSense.hDefine ChannelBusy and pure vendor conversions +132/-1

Define ChannelBusy and pure vendor conversions

• Adds provenance-aware busy-airtime and energy-above-floor fields. Pure helpers convert Realtek CLM/NHM and MediaTek busy/idle counters while rejecting unavailable readings.

src/RxSense.h

RtlJaguarDevice.cppAdvertise Jaguar1 sensing capabilities +5/-0

Advertise Jaguar1 sensing capabilities

• Marks CLM and RxEnergy support as implemented while leaving busy-airtime measurement validation false.

src/jaguar1/RtlJaguarDevice.cpp

RtlJaguar2Device.cppAdvertise validated Jaguar2 sensing +4/-0

Advertise validated Jaguar2 sensing

• Declares CLM busy airtime and RxEnergy support, including existing on-air validation.

src/jaguar2/RtlJaguar2Device.cpp

RtlJaguar3Device.cppAdvertise validated Jaguar3 sensing +4/-0

Advertise validated Jaguar3 sensing

• Declares CLM busy airtime and RxEnergy support, including existing on-air validation.

src/jaguar3/RtlJaguar3Device.cpp

RtlKestrelDevice.cppDeclare Kestrel frame-free sensing unavailable +5/-0

Declare Kestrel frame-free sensing unavailable

• Explicitly reports no CLM busy-airtime or phydm FA/CCA/IGI support instead of relying on interface inheritance.

src/kestrel/RtlKestrelDevice.cpp

Mt7612uRadio.cppImplement MediaTek ChannelBusy readings +32/-0

Implement MediaTek ChannelBusy readings

• Reads synchronized busy/idle timer deltas through the narrow C accessor and converts them into neutral occupancy. Capability metadata marks the path implemented but unmeasured.

src/mt7612u/Mt7612uRadio.cpp

Mt7612uRadio.hExpose MediaTek GetChannelBusy override +1/-0

Expose MediaTek GetChannelBusy override

• Declares the MT7612U implementation of the neutral channel-busy interface.

src/mt7612u/Mt7612uRadio.h

mt7612u.hDeclare the narrow channel-time accessor +25/-0

Declare the narrow channel-time accessor

• Adds a checked busy/idle timer API that avoids consuming the false-CCA counter owned by AGC.

src/mt7612u/include/mt7612u/mt7612u.h

init.cppRead MediaTek busy and idle timer deltas +24/-0

Read MediaTek busy and idle timer deltas

• Implements checked register reads and reports a per-device host interval without touching unrelated link statistics.

src/mt7612u/init.cpp

internal.hTrack channel-time polling intervals separately +4/-0

Track channel-time polling intervals separately

• Adds a per-device timestamp dedicated to GetChannelBusy so link-stat polling retains independent interval semantics.

src/mt7612u/internal.h

Rtl8733bDevice.cppDeclare RTL8733B sensing unavailable +5/-0

Declare RTL8733B sensing unavailable

• Explicitly disables busy-airtime and RxEnergy capabilities because the backend implements neither sensor.

src/rtl8733b/Rtl8733bDevice.cpp

DwellExecutor.hAdd reusable channel-survey dwell execution +250/-0

Add reusable channel-survey dwell execution

• Introduces caller-driven begin, barrier, and finish phases that retune radios, collect counter and frame evidence, and populate SurveyDwell. It also fixes width restoration after failed full-gate retunes.

src/sensing/DwellExecutor.h

SenseWindow.cppProvide the default monotonic sensing clock +13/-0

Provide the default monotonic sensing clock

• Implements the steady-clock microsecond source used for internal duration measurements.

src/sensing/SenseWindow.cpp

SenseWindow.hCentralize sensing-window counter discipline +166/-0

Centralize sensing-window counter discipline

• Implements discard barriers, measured observation windows, NHM reduction, counter plausibility checks, and read-failure latching.

src/sensing/SenseWindow.h

SurveyFrameAgg.hAdd configurable thread-safe survey aggregation +151/-0

Add configurable thread-safe survey aggregation

• Extracts frame quality and airtime folding from chanscout. Source-address ownership is configurable, and reset/drain operations protect dwell boundaries across threads.

src/sensing/SurveyFrameAgg.h

Refactor (2) +47 / -186
main.cppAdopt the reusable dwell executor +40/-186

Adopt the reusable dwell executor

• Replaces the demo-local dwell and frame aggregation implementation with DwellExecutor and SurveyFrameAggregator. Scheduling, sleeping, health policy, and event emission remain in the example.

examples/chanscout/main.cpp

LinkHealth.hMake IGI rails configurable +7/-0

Make IGI rails configurable

• Moves Realtek-derived DIG limits into LinkHealthThresholds so neutral quality-building code no longer hardcodes family constants.

src/LinkHealth.h

Tests (3) +636 / -0
channel_busy_selftest.cppTest neutral channel-busy conversions +126/-0

Test neutral channel-busy conversions

• Covers invalid readings, source provenance, clamping, rounding, timer intervals, and 64-bit ratio arithmetic without hardware.

tests/channel_busy_selftest.cpp

dwell_exec_selftest.cppTest dwell sequencing and failure recovery +459/-0

Test dwell sequencing and failure recovery

• Uses scripted radios and clocks to verify barriers, width restoration, retune failures, counter limits, frame attribution, neutral radios, and scheduler bookkeeping.

tests/dwell_exec_selftest.cpp

radio_iface_selftest.cppTest the neutral sensing interface contract +51/-0

Test the neutral sensing interface contract

• Verifies default-invalid behavior, capability defaults, unsupported Realtek behavior, and inherited CLM conversion through IRtlRadio.

tests/radio_iface_selftest.cpp

Documentation (5) +140 / -3
CLAUDE.mdDocument the sensing helper subtree +6/-1

Document the sensing helper subtree

• Adds the new device-touching sensing layer to the repository architecture guidance and clarifies its ownership boundaries.

CLAUDE.md

rx-spectrum-sensing.mdDocument portable and Realtek sensing surfaces +23/-0

Document portable and Realtek sensing surfaces

• Explains GetChannelBusy versus GetRxEnergy, capability flags, unsupported adapters, and source-dependent busy semantics.

docs/rx-spectrum-sensing.md

CLAUDE.mdDocument the extracted survey acquisition layer +6/-2

Document the extracted survey acquisition layer

• Clarifies that sensing now owns device-facing dwell execution while channel migration remains pure and hardware-independent.

src/chanmig/CLAUDE.md

CLAUDE.mdDocument MediaTek channel-timer sensing +24/-0

Document MediaTek channel-timer sensing

• Records timer registers, ownership constraints, failure handling, busy semantics, and the absence of hardware validation.

src/mt7612u/CLAUDE.md

CLAUDE.mdDefine sensing-layer architecture and invariants +81/-0

Define sensing-layer architecture and invariants

• Documents thread, sleep, clock, dependency, barrier, width-restoration, and validation contracts for the new subtree.

src/sensing/CLAUDE.md

Other (1) +27 / -0
CMakeLists.txtBuild sensing primitives and register selftests +27/-0

Build sensing primitives and register selftests

• Adds the sensing sources to devourer and registers headless tests for ChannelBusy conversion and dwell execution.

CMakeLists.txt

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 17, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Survey contracts are duplicated at root ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
CLAUDE.md restates the sensing library's thread, sleep, and clock-ownership contracts even though
it points to the nested guidance and those contracts are documented in SenseWindow.h. Changes to
the library contract must therefore be synchronized across the header, root guidance, and subtree
guidance, making stale repository instructions likely.
Code

CLAUDE.md[R588-591]

+- `sensing/` — the one helper subtree that CALLS device methods: the
+  channel-survey dwell executor and the shared settle/barrier/observe window
+  (`src/sensing/CLAUDE.md`). It owns no thread, performs no sleep and takes no
+  clock of record, which is what lets `chanmig/` and `hopset/` stay pure.
Evidence
Rule 2 requires CLAUDE guidance to refer to authoritative headers rather than copying their
contracts. The root entry repeats the no-thread, no-sleep, and no-clock behavior documented by
SenseWindow.h.

CLAUDE.md: Do Not Duplicate Header Documentation in CLAUDE.md: CLAUDE.md: Do Not Duplicate Header Documentation in CLAUDE.md: CLAUDE.md: Do Not Duplicate Header Documentation in CLAUDE.md: CLAUDE.md: Do Not Duplicate Header Documentation in CLAUDE.md
CLAUDE.md[588-591]
src/sensing/SenseWindow.h[15-25]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The root guidance duplicates lifecycle contracts already documented by the sensing header and nested guidance.
## Fix Focus Areas
- CLAUDE.md[588-591]
## Recommended Fix
Reduce the root entry to a concise architectural index pointing to `src/sensing/CLAUDE.md` and `src/sensing/SenseWindow.h`; remove the repeated thread, sleep, and clock contract details.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Survey guidance repeats header details ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
src/sensing/CLAUDE.md repeats SenseWindow sequencing, helper behavior, and null-radio semantics
that are already documented alongside the API in SenseWindow.h. A later sequencing or fallback
change can leave the repository guidance describing behavior the implementation no longer provides.
Code

src/sensing/CLAUDE.md[R34-37]

+- **`SenseWindow.h`** — the shared discipline: settle → DISCARD BARRIER →
+  observe → read, in microseconds, with no retune and no frame term. Plus
+  `reduce_nhm()` and `counters_implausible()` as free functions. A null
+  `IRtlRadio*` is a first-class case (non-Realtek radio): the barrier becomes a
Evidence
Rule 2 establishes headers as the authoritative home for API contracts. The CLAUDE section repeats
the barrier sequence from the header and the null-radio behavior documented on the SenseWindow
constructor and methods.

CLAUDE.md: Do Not Duplicate Header Documentation in CLAUDE.md: CLAUDE.md: Do Not Duplicate Header Documentation in CLAUDE.md: CLAUDE.md: Do Not Duplicate Header Documentation in CLAUDE.md: CLAUDE.md: Do Not Duplicate Header Documentation in CLAUDE.md
src/sensing/CLAUDE.md[34-44]
src/sensing/SenseWindow.h[1-19]
src/sensing/SenseWindow.h[120-138]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The sensing guidance copies API sequencing and fallback contracts from the authoritative header.
## Fix Focus Areas
- src/sensing/CLAUDE.md[34-44]
## Recommended Fix
Replace the copied sequence and null-radio behavior with a concise pointer to `src/sensing/SenseWindow.h`, retaining only subtree-specific navigation or rationale not documented by the API.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. MediaTek guidance repeats API contracts ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
src/mt7612u/CLAUDE.md copies the channel-timer ownership, read-and-clear behavior, and failed-read
handling already documented on mt7612u_ch_time. Changes to register ownership or error semantics
can consequently leave backend guidance contradicting the public C interface.
Code

src/mt7612u/CLAUDE.md[R11-14]

+It goes through `mt7612u_ch_time()`, **not** `mt7612u_link_stats()`, and that
+is the point: `link_stats` also reads `MT_RX_STAT_1`, whose false-CCA field is
+read-and-clear and owned by `mt7612u_phy_tick()`'s AGC loop. A second reader at
+caller cadence would both misreport the figure and starve the gain tracking.
Evidence
Rule 2 prohibits repeating header contracts in CLAUDE files. Both cited regions describe the same
dedicated registers, false-CCA ownership, read-and-clear semantics, and checked-read failure
behavior.

CLAUDE.md: Do Not Duplicate Header Documentation in CLAUDE.md: CLAUDE.md: Do Not Duplicate Header Documentation in CLAUDE.md: CLAUDE.md: Do Not Duplicate Header Documentation in CLAUDE.md: CLAUDE.md: Do Not Duplicate Header Documentation in CLAUDE.md
src/mt7612u/CLAUDE.md[11-17]
src/mt7612u/include/mt7612u/mt7612u.h[421-444]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The MediaTek guidance duplicates the detailed channel-timer contract from the authoritative C header.
## Fix Focus Areas
- src/mt7612u/CLAUDE.md[11-17]
## Recommended Fix
Replace the copied register ownership, read behavior, and failure semantics with a pointer to the `mt7612u_ch_time` documentation in `src/mt7612u/include/mt7612u/mt7612u.h`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View action required (4)
4. Readers see a rate without its control ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
src/mt7612u/CLAUDE.md quotes the favorable 5415-5470 fps result but omits the authoritative
benchmark's no-tick result of three frames in ten seconds and its eight-run, two-adapter
qualification. When readers use that figure to design the proposed poller comparison, the copied
fragment lacks the control and scope needed to interpret it.
Code

src/mt7612u/CLAUDE.md[R23-24]

+(frame rate against a steady peer with and without the poller, the methodology
+behind the tick's 5415-5470 fps figure).
Evidence
Rule 3 requires favorable measurements to remain with their adverse counterparts and qualifications,
while Rule 2 requires the header to remain authoritative. The CLAUDE addition copies only the
favorable rate even though the header explicitly records the no-tick control, run count, adapter
scope, and states that it is the figure's single home.

CLAUDE.md: Report Favorable Measurements with Their Adversarial Counterparts: CLAUDE.md: Report Favorable Measurements with Their Adversarial Counterparts: CLAUDE.md: Report Favorable Measurements with Their Adversarial Counterparts: CLAUDE.md: Report Favorable Measurements with Their Adversarial Counterparts
CLAUDE.md: Do Not Duplicate Header Documentation in CLAUDE.md: CLAUDE.md: Do Not Duplicate Header Documentation in CLAUDE.md: CLAUDE.md: Do Not Duplicate Header Documentation in CLAUDE.md: CLAUDE.md: Do Not Duplicate Header Documentation in CLAUDE.md
src/mt7612u/CLAUDE.md[23-24]
src/mt7612u/include/mt7612u/mt7612u.h[406-413]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The CLAUDE file repeats only the favorable portion of a controlled benchmark whose complete result belongs in the API header.
## Fix Focus Areas
- src/mt7612u/CLAUDE.md[23-24]
## Recommended Fix
Remove the numeric rate from the CLAUDE file and point readers to the complete controlled PHY-tick benchmark in `src/mt7612u/include/mt7612u/mt7612u.h`, where the adverse result and run qualifications remain together.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. MediaTek sensing reaches no decisions ✓ Resolved 🐞 Bug ≡ Correctness
Description
DwellExecutor constructs SenseWindow from an IRtlRadio* and reads only GetRxEnergy(), while
the survey record and hopset sample have no path for IRadio::GetChannelBusy(). On MT7612U the
Realtek pointer is null, so channel surveys emit invalid frame-free fields and transmit-side hopset
sensing exits without using the newly implemented busy-airtime reading.
Code

src/sensing/DwellExecutor.h[R57-60]

+  DwellExecutor(IRadio *radio, IRtlRadio *rtl, SurveyFrameAggregator *agg,
+                const DwellExecConfig &cfg)
+      : radio_(radio), agg_(agg), cfg_(cfg), sense_(rtl, cfg.clock),
+        clock_(cfg.clock ? cfg.clock : MonotonicUs(&steady_us)) {}
Evidence
The neutral method exists and MT7612U implements and advertises it, but the new executor accepts a
separate Realtek pointer and its sensing helper touches only that pointer. The emitted survey schema
contains only Realtek-shaped FA, NHM and CLM fields, while the existing hopset acquisition similarly
requires IRtlRadio and calls GetRxEnergy, proving that MediaTek busy timer results cannot reach
either adaptive path.

src/IRadio.h[554-575]
src/mt7612u/Mt7612uRadio.cpp[824-839]
src/mt7612u/Mt7612uRadio.cpp[1088-1094]
src/sensing/SenseWindow.h[122-149]
src/chanmig/SurveyRecord.h[52-79]
src/chanmig/EvidenceStore.h[251-271]
examples/tx/main.cpp[225-247]
src/hopset/HopsetSense.h[201-240]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new vendor-neutral channel-busy reading is not consumed by the survey executor or hopset path, leaving MT7612U busy-airtime evidence disconnected from adaptive decisions.
## Fix Focus Areas
- src/sensing/DwellExecutor.h[57-60]
- src/sensing/SenseWindow.h[120-150]
- src/chanmig/SurveyRecord.h[52-79]
- src/chanmig/EvidenceStore.h[251-271]
- src/hopset/HopsetSense.h[65-82]
- examples/tx/main.cpp[225-295]
## Recommended Fix
Add neutral busy-airtime fields and provenance to the survey and hopset evidence types, then acquire `IRadio::GetChannelBusy()` at the discard barrier and observation boundary for non-Realtek radios. Preserve the existing single `GetRxEnergy()` path on Realtek and derive its neutral value from that same read so shared delta counters are not consumed twice; finally feed valid neutral busy airtime into the applicable channel-ranking inputs.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Transmitters cannot measure channel load ✓ Resolved 🐞 Bug ≡ Correctness
Description
Mt7612uRadio::GetChannelBusy() calls mt7612u_ch_time() whenever _dev exists, but the channel
timers are armed and cleared only by StartRxLoop(), not by InitWrite(). A transmit-only hopset
sensor can therefore read disabled or stale timer registers after MAC startup, producing no reading
or treating retained nonzero busy-plus-idle counts as a valid percentage even though no sensing
window was armed.
Code

src/mt7612u/Mt7612uRadio.cpp[R831-836]

+    /* Refuses before bring-up: the timers are armed by
+     * mt7612u_link_stats_start() in StartRxLoop, and a read before that is a
+     * counter that was never enabled — indistinguishable from an idle channel
+     * if it were reported as one. */
+    if (!_dev || mt7612u_ch_time(_dev, &busy, &idle, &interval_us) != 0)
+      return {};
Evidence
InitWrite() brings up the device and starts the MAC without initializing link statistics, while
StartRxLoop() explicitly calls mt7612u_link_stats_start() to arm and clear the channel timers.
GetChannelBusy() checks only that _dev exists before reading those registers, and the conversion
accepts any nonzero busy-plus-idle total as valid, demonstrating that the documented transmit-side
use can consume residual counts without a safely initialized sensing window.

src/mt7612u/Mt7612uRadio.cpp[337-350]
src/mt7612u/Mt7612uRadio.cpp[405-411]
src/mt7612u/Mt7612uRadio.cpp[824-839]
src/mt7612u/init.cpp[589-604]
src/mt7612u/init.cpp[607-628]
src/RxSense.h[201-217]
src/mt7612u/Mt7612uRadio.cpp[824-838]
src/RxSense.h[201-209]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`GetChannelBusy()` is available after `InitWrite()`, but the MT7612U channel timers are armed and cleared only during RX-loop startup. Transmit-only sensing can consequently interpret stale hardware counts as a valid channel-busy result because the API does not verify that a sensing window was initialized.
## Fix Focus Areas
- src/mt7612u/Mt7612uRadio.cpp[337-350]
- src/mt7612u/Mt7612uRadio.cpp[405-411]
- src/mt7612u/Mt7612uRadio.cpp[409-411]
- src/mt7612u/Mt7612uRadio.cpp[824-838]
- src/mt7612u/init.cpp[589-628]
- src/mt7612u/internal.h[212-216]
## Recommended Fix
Arm and clear the channel timers after successful MAC startup in both RX and transmit-only initialization paths, preferably through a shared initialization helper using the same setup for both session types. Track the armed state per device, reset `ch_time_last_us` whenever the timers are armed, and make `mt7612u_ch_time()` refuse reads while unarmed so reads before setup return no reading and residual register contents cannot become valid measurements.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Retuned radios mix two channels ✓ Resolved 🐞 Bug ≡ Correctness
Description
mt7612u_ch_time() retains ch_time_last_us across reads, but SetMonitorChannel() neither clears
the read-and-clear channel timers nor resets that mark after a successful retune. The first sample
after switching channels can therefore include old-channel airtime and an interval spanning the
retune while being returned as a valid reading for the newly selected channel.
Code

src/mt7612u/init.cpp[R623-627]

+	if (interval_us)
+		*interval_us = d->ch_time_last_us
+		                   ? (uint32_t)(now - d->ch_time_last_us)
+		                   : 0; /* first call: no previous mark */
+	d->ch_time_last_us = now;
Evidence
The new reader reports the raw timer values as a delta and derives its interval from a newly added
per-device mark. A successful channel switch only changes PHY/channel state; it does not reset
either the timers or this mark, despite the timer registers being documented as read-and-clear.

src/mt7612u/Mt7612uRadio.cpp[553-572]
src/mt7612u/init.cpp[607-628]
src/mt7612u/include/mt7612u/mt7612u.h[432-436]
src/RxSense.h[203-215]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
Issue description
The channel-busy timer state persists across `SetMonitorChannel()` calls. The next sample can combine occupancy collected before and after the retune, so it is not attributable to the selected channel.
Fix Focus Areas
- src/mt7612u/Mt7612uRadio.cpp[553-572]
- src/mt7612u/init.cpp[589-605]
- src/mt7612u/init.cpp[623-627]
Recommended Fix
After a successful live channel change, re-arm and clear the channel timers and reset `ch_time_last_us` so the next read is either a clean post-retune interval or explicitly has an unknown interval. Apply the same mark reset inside the timer-start helper so all future re-arm paths are correct.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can enable the Remediation agent and Qodo fixes findings in a dedicated fix PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread CLAUDE.md Outdated
Comment thread src/sensing/CLAUDE.md Outdated
Comment thread src/mt7612u/CLAUDE.md Outdated
Comment thread src/mt7612u/CLAUDE.md Outdated
Comment thread src/sensing/DwellExecutor.h
Comment thread src/mt7612u/Mt7612uRadio.cpp Outdated
Comment thread src/mt7612u/init.cpp
…estly

Addresses the Qodo review on #432. Three real bugs and four
documentation-duplication findings.

## The neutral contract reached no decisions (finding 5)

`DwellExecutor` built its `SenseWindow` from an `IRtlRadio*` and read only
`GetRxEnergy`, so on a non-Realtek radio the dwell carried frame evidence but
no busy airtime — the reading commit 1 had just added was implemented and then
never consumed. The PR claimed "a plain IRadio still produces usable dwells";
that was true only of the frame half.

`SenseWindow` now takes the `IRadio*` as well. On a Realtek radio the neutral
value is DERIVED from the `GetRxEnergy` read already taken — a second
`GetChannelBusy` would drain the same delta counters twice and halve both
readings. On any other backend it calls `GetChannelBusy` directly, at the
barrier as well as the observation, because those counters are read-and-clear
too and the barrier is what scopes the window.

`SurveyDwell` gains `busy_source` (schema v3; the parser still accepts v1 and
v2). Provenance is not decoration: a channel-timer family counts the radio's
own TX as busy and Realtek's CLM does not, so ranking across a mixed pair of
scouts compares two rulers. Having argued that in the PR, dropping it at the
record boundary would have been inconsistent.

The selftest now proves the claim rather than asserting it: a radio with only
`GetChannelBusy` and no `IRtlRadio` anywhere produces a dwell carrying real
busy airtime with its provenance, and one offering no reading records none —
not 0% busy.

## MediaTek timers could be read unarmed or across a retune (findings 6, 7)

Both are the kind of thing on-air testing would have caught, and this path has
none.

The timers were armed only in `StartRxLoop`, so a transmit-only session read
registers nobody had configured — and `busy_from_ch_time` would have turned a
previous session's residue into a perfectly plausible percentage for a window
that was never measured. They are now armed on the transmit-only path too, an
`ch_time_armed` flag is tracked per device, and `mt7612u_ch_time()` refuses
while unarmed.

`SetMonitorChannel` neither cleared the counters nor reset the interval mark,
so the first sample after a retune mixed the old channel's airtime into the new
channel's reading and reported it as valid. It now re-arms, which clears both.

## Documentation (findings 1-4)

The root and subtree guidance restated contracts the headers already own;
trimmed to pointers and the rationale that is genuinely subtree-specific.

Finding 4 is the one that mattered: `src/mt7612u/CLAUDE.md` quoted the
favourable half of a controlled benchmark — the tick's 5415-5470 fps — without
its no-tick control of three frames in ten seconds. That is exactly the standing
rule in the root file, "never quote a favourable measurement without its
adversarial counterpart in the same breath". The number is gone; readers are
pointed at the complete figure where the control sits beside it.

## Validation

66/66 ctest; jaguar1-only, jaguar3-only and mt7612u-only subsets pass.

On air, 8812CU, baseline rebuilt from master and re-run back to back because
the record schema and the executor both changed: 314 dwells each side, seq
gapless, identical flag histogram, no field lost, `observe_ms` 112 -> 112 (0%
drift), `retune_us` 1347 -> 1294 (4%). Every Realtek record now carries
`busy_src: 1` alongside an unchanged `clm`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Au9D2ntoFn4ABLJqp9vYDk
@josephnef
josephnef enabled auto-merge (squash) September 17, 2026 12:43
@josephnef
josephnef merged commit cb98f10 into master Sep 17, 2026
37 of 40 checks passed
@josephnef
josephnef deleted the feat/neutral-channel-busy branch September 17, 2026 12:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant