mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-11-03 11:52:22 +01:00
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.