Files
pyMC_Repeater/repeater/handler_helpers
agessaman d4aca403a5 fix(duty-cycle): meter each radio against its own channel
A duty cycle is a limit on a channel, not on a node, and one AirtimeManager
charging every radio at the top-level radio block's modulation is wrong twice
over on a bridge. The same packet occupies a 62.5 kHz channel about eight times
longer than a 500 kHz one, so whichever bandwidth that block names, the other
radio is mis-charged by that ratio; and the two share one allowance, so a busy
local radio spends the backhaul's airtime on a band it never transmits on.

There is now one budget per channel, built from each radio's own profile and
its own band's limit -- 868.0-868.6 MHz allows 1% where 869.4-869.65 allows
10%, and one number cannot describe both. Two radios tuned alike share a
budget, because they contend for the same spectrum. duty_cycle.shared_budget
restores a single node-wide allowance for anyone who wants the conservative
reading, and a single-radio node is metered exactly as it was.

Metering a channel means knowing which radio will carry a packet, and the
engine used to work that out by restating openhop_core's dispatch rules. It
asks now. openhop_core passes a packet's ingress radio to the TX policy, so the
choice is a function of the packet rather than of whatever arrived most
recently; the repeater asks before scheduling and gets the answer the send will
reach. That also closes a race that predates this: sticky and bridge read the
node's last RX at send time, which after a retransmit delay on a busy node is
another packet's arrival, and moves a reply onto the wrong band.

The core change is optional. The keyword in resolve_tx_radio_id's signature is
the version check, and against a core without it the tx_mode selectors are
registered in their original one-argument form and behave exactly as they did.
What changes there is the gate: with no way to know which radio will transmit,
every radio the fabric could pick is held to its own budget, so a node cannot
transmit past a legal limit by guessing the quiet channel. Which mode is in
force is logged at startup.

Also fixed, each reproduced by an adversarial review of the earlier attempt:

- A node with one radios[] entry had its modulation and limit overwritten by
  the stale top-level block on every radio save from the web UI. The refresh
  path now branches on whether profiles were read, not on how many radios
  there are.
- build_radio_profiles is all-or-nothing by design -- it feeds the UI -- so one
  radio_type: none collapsed metering onto the top-level block for the radio
  still transmitting. Metering has its own builder that degrades per radio.
- Carrying the whole node's spend onto an unrecognised channel compounded over
  repeated rebuilds (100 -> 400 -> 1600 ms) and wedged transmission for the
  rest of the window. Carry-over is keyed by channel, which makes an unchanged
  rebuild idempotent, and an unrecognised one starts from the busiest single
  channel rather than the sum of them.
- A rebuild worked the previous default manager out from the config again,
  which by then already named the new default -- preserving the wrong object
  and orphaning the one NeighborScopeHelper holds for the life of the process.
  It is remembered rather than recomputed.
- Every caller that reports one airtime figure was reporting the default
  radio's channel: the MeshCore wire field total_air_time_secs, companion
  stats, the RRD and SQLite history, and /stats. They read a node total again
  -- totals summed across channels, utilisation the highest of them, because a
  node at its limit on one channel is at a limit however quiet the other is.

tests/test_fabric_fanout.py's rig never bound a tx_mode selector, so all 49 of
those tests ran against a fabric with no TX policy at all. It binds the real
one now; that gap is why an on-air change went unnoticed through two reviews.
2026-09-13 10:45:58 -07:00
..