Radio: lanes & presets
The radio layer’s job is to make a half-duplex, single-channel-at-a-time transceiver behave like a multi-speed switched network. The core abstraction is the lane: a (frequency, preset, schedule) triple. A TCP link is just a lane with infinite speed and no schedule.
Preset ladder
Section titled “Preset ladder”Eight presets, chosen to cover the Meshtastic/MeshCore operating envelope
(values cross-checked against Meshtastic’s MeshRadio.h table):
| id | Name | SF | BW kHz | CR | ~kbps | Role |
|---|---|---|---|---|---|---|
| P0 | ANCHOR | 12 | 125 | 4/8 | 0.18 | beacons, discovery, last-resort uplink |
| P1 | FRINGE | 11 | 125 | 4/8 | 0.34 | very long links, fringe coverage |
| P2 | REACH | 10 | 250 | 4/6 | 1.4 | long client links |
| P3 | FIELD | 9 | 250 | 4/5 | 2.6 | default client lane |
| P4 | CRUISE | 8 | 250 | 4/5 | 4.7 | good client links, slow backbone |
| P5 | LOCAL | 7 | 250 | 4/5 | 8.5 | near clients, backbone |
| P6 | TRUNK | 7 | 500 | 4/5 | 17 | backbone, DCC bursts |
| P7 | BURST | 5 | 500 | 4/5 | 39 | short-range backbone, DCC bursts |
The names are link-role terminology, not speeds for their own sake: FRINGE holds the fringe of a cell, FIELD is the default lane a client in the field sits on, TRUNK is the backbone trunk line, BURST carries DCC bursts. The preset id is what travels on the wire; names are display/config aliases (lowercase accepted), so renames are never a wire event.
All eight are decodable by SX126x/SX127x-class silicon. Sync word and preamble follow LoRa public-network conventions; chIRpChat traffic is additionally self-identified by the LPP version bits, so coexistence with foreign LoRa traffic fails safe (undecodable or unknown-version → ignored).
Frequency slots are computed within the regional band plan
(EU868/US915/AU915/AS923… tables in core/include/lrc/presets.h) as
slot = SipHash(region ‖ lane_index) % nslots offsets from the band start —
deterministic, so two nodes with the same config land on the same
frequencies without negotiation (same idea as Meshtastic’s name-hash slots,
minus the string hashing). The allocator then deterministically probes forward
from the hashed slot past any whose passband would fall on — or within a guard
band of — a busy neighbouring-network channel, so chIRpChat isn’t a bad neighbour to
the heaviest LoRa traffic in a region. Two sources are dodged (presets.h):
MeshCore’s fixed channels and every Meshtastic modem preset.
For MeshCore that means the coordinated US/CAN default (910.525 MHz, BW62.5 —
the single busiest LoRa-mesh channel in US915), plus a couple of high-traffic
metros that deliberately run off it: Southern California / “West Coast Mesh”
(927.875 MHz, up by the 928 MHz band edge) and Sacramento / Sac Valley Mesh
(909.875 MHz). Those two are region-specific — a chIRpChat node outside those metros
loses nothing by keeping the slot clear, and SoCal’s sits high enough it only
ever costs the topmost BW500 lane slot. Each is a {center, bandwidth} entry in
kMeshAvoidChannels, cited in-line.
For Meshtastic that’s every modem preset — LongFast, MediumFast/MediumSlow,
ShortFast/ShortSlow, LongModerate/LongSlow, ShortTurbo, and the deprecated
VeryLongSlow. Because
Meshtastic places each preset’s default channel with a djb2 name-hash, those land
on different frequencies, so chIRpChat reproduces Meshtastic’s exact slot calculation
(meshtastic_preset_freq_khz, verified against the published US915 LongFast
906.875 MHz; bandwidth is carried in Hz so the 62.5 kHz presets place exactly).
The guard band widens with the neighbour’s bandwidth, so the 500 kHz ShortTurbo
reserves correspondingly more room. Both ends walk the same slots, so they still
agree without negotiation; in a band too narrow to dodge a co-located channel
(e.g. EU868’s 250 kHz subband) it falls back to the hashed slot. Every preset is
listed even though a niche one carries little traffic today — any can become a
local default — and the cost is small: ~9 channels out of US915’s 104. On US915
the chIRpChat anchor (908.687 MHz) and lanes are already clear of all of them, so
nothing moves.
The lane plan
Section titled “The lane plan”A router runs a small number of lanes concurrently in time-division, since
the radio can only do one at once (multi-radio routers — Linux lrcd with
several HATs — run them truly in parallel, one radio per lane). The
lane plan is the router’s published schedule, carried in every BEACON:
BEACON { rtr_short(2), RK_fp(8), flags(1: busy, fed_member, accepts_new), n_lanes(1), lanes[n]: { lane_id(1), preset(1), freq_slot(1), period_s(1), offset_s(1), window_s(1) }, fed_id(4), time(4), boot_id(4)}period/offset define when the router parks its receiver on that lane
(e.g. lane 2 = P5 every 10 s at offset 3 for 2 s). Clients TX to the router
inside the lane’s window; the router TXes its downlink batches at the
window head, then listens for the remainder of the window
(“listen-after-talk”) — this is the answer to “how does anyone know when to
speak on which frequency/SF”: the router publishes its calendar, then opens
the floor after each of its own transmissions. LANEPROBE is the fixed
8-byte “floor is open, anyone with traffic for me speak now” solicitation
emitted when the router has nothing to send but a non-anchor window is open;
it names the lane and remaining listen budget, while CAD/backoff still gates
actual uplink TX. The host-tested LaneProbeEmitter builds one probe frame
per listen window from the same lane plan that BEACON advertises.
LaneSchedule::runtime_decision is the matching host-tested single-radio
runtime helper: it chooses the fastest currently open non-anchor lane that the
radio can support, subtracts a caller-provided guard interval from the usable
window, and falls back to anchor when the edge is too close. Firmware wires
the probe emitter and runtime retune service through the existing TX queue/CAD
path and leaves the fresh-device receive-only guard intact; the default
firmware plan is still the single always-on anchor lane until local
multi-lane runtime retuning is enabled.
The cold lrckv tier can persist configured lane definitions as a laneplan
record, but it stores only LaneDef schedule data; BEACON runtime fields
(RK_fp, time, boot_id, and router flags) are filled when the beacon is
emitted and are not restored from flash. Allowlisted signed Admin can update
that same store with lane.plan using the compact
lane_id:preset:freq_slot:period_s:offset_s:window_s rows documented in the
user guide. ESP32 firmware restores stored plans from that record. Anchor-only
plans are advertised immediately; non-anchor rows are advertised and used by
the single-radio retune service only after the local console command
lane retune on is set on that device. lane.plan stores intent, not remote
RF authorization.
BeaconCadenceController is the host-tested anchor-beacon congestion policy:
the first beacon is immediate, busy/not-accepting/client-active routers use the
active cadence, idle routers use the idle cadence, and an idle router that has
recently heard a healthy same-federation neighbor backs off to the suppressed
cadence while still respecting the configured maximum interval (default 600 s).
Visibility-critical periods such as burst-mode operation can force the minimum
cadence (default 60 s). ESP32 firmware wires this decision into its router
beacon loop and marks cadence when a BEACON is accepted into the TX queue;
actual TX still goes through the existing receive-only gate, queue, and CAD
path.
Beacon suppression keeps routers off the anchor; LaneSchedule::promote_client
is the matching host-tested policy that keeps clients off it. It is the
router-side mirror of pick_uplink_lane: once the router has more than
min_clients registered (the anchor is congested enough to matter), a client
whose uplink the router measured with margin over a faster lane’s probe floor
(default 3 dB over presets.h) and whose caps allow that preset is promoted to
the fastest such lane. A per-client debounce (repromote_hold_ms, default 60 s)
keeps the ladder climb monotonic instead of thrashing, and below the congestion
threshold clients are simply left on the dial tone. The policy is pure; the
decision travels to the client as a LANEGRANT unicast (PROTOCOL.md §Lane
promotion) Tag8’d with the session key. ESP32 firmware wires both ends: a router
runs promote_client against the SNR it measured on a client’s HELLO and emits
the grant right after WELCOME (lane.grant_tx); a client validates the grant is
for it and from its router (lane.grant_rx) and, when its local lane retune on RF opt-in is set, retunes its uplink onto the granted lane until the TTL
lapses, then falls back to the anchor. A grant only fires when the router
actually advertises a faster lane the client can sustain.
promote_client decides the first move; LaneSchedule::promote_lifecycle is
the host-tested wrapper that manages the grant’s life after that, since a
LANEGRANT TTL is finite. Given the live grant the router stored (lane, TTL,
expiry), the latest uplink SNR, recent-uplink health, and the same congestion
input, it returns one of four actions: GRANT a first lane (it defers the
whole “is it worth it / which lane / debounce” judgement to promote_client, so
GRANT lands on exactly the one-shot lane); RENEW the live grant before it
expires — only inside renew_lead_ms of the stored expiry, only for a grant that
carried a real TTL, and only while the link still sustains the lane, re-issued
onto the same lane or a faster one if the link improved so renewals can also
climb; DEMOTE back to the anchor when the TTL lapsed, when a run of
failed/absent uplinks (demote_fail_run) says the client went dark, or when the
uplink SNR sits below the granted lane’s sustain floor (its preset probe floor
plus demote_snr_margin_x10) for demote_snr_run consecutive samples — the
hysteresis run keeps a single dip from dropping the lane, and a recovered latest
sample cancels a stale run; or HOLD. Demotion is evaluated before renewal so
a decaying link is pulled down rather than re-leased onto a lane it can no longer
hold. Like promote_client it owns no per-client map — the caller stores the
grant and link signals and passes them back — and it is pure, allocation-free,
and RF-neutral: it computes the intent to (re)issue or revoke a grant and
authorizes no TX or retune. Firmware/lrcd translate GRANT/RENEW into the
LANEGRANT unicast and DEMOTE into the client’s anchor fallback.
Clients needing no traffic just sleep through windows (POWER.md). The anchor lane (P0) is always lane 0 and always reachable — it’s the network’s dial tone.
boot_id is the router’s current incarnation, generated fresh on boot and
repeated in signed WELCOME. Unsigned BEACON boot IDs are advisory; once a
signed WELCOME confirms a changed nonzero boot ID, clients flush stale
per-router mirror and gap state instead of treating the reset as a huge
sequence gap.
Lane probing (how a link finds its speed)
Section titled “Lane probing (how a link finds its speed)”During registration and once per relink_interval afterward:
- Client hears BEACON on P0, sends HELLO on P0 or directly on a faster
advertised lane if RSSI/SNR of the beacon clears that lane’s threshold
(thresholds per preset in
presets.h, derived from SX1262 sensitivity floors: e.g. SNR ≥ -7.5 dB → try P3, ≥ 5 dB → try P5). - WELCOME comes back on the same lane; its reported RSSI/SNR of the HELLO lets the client decide to retry one rung higher (“ladder climb”, max 2 climbs per registration to bound the chatter).
- The negotiated rung is pinned per link and re-probed on 5 consecutive failures (down) or weekly (up).
Routers with band-pass filter constraints (cavity/SAW-filtered routers
that can only emit in, say, a 5 MHz window) advertise only freq slots inside
their window — the constraint is config (rf.freq_min/freq_max and an
optional explicit slot allowlist), and the lane plan simply never offers
what the hardware can’t do.
Relays as lane translators
Section titled “Relays as lane translators”A relay (router built with RELAY_ONLY) parks RX on lane A and re-TXes
held packets on lane B per its own mini lane plan — receive on P1 from the
valley, forward on P6 to the hilltop router. This is plain store-and-forward
with the relay appearing in path blocks like any hop; no protocol changes.
Relays answer CHANSYNC from their ring buffer when they can (PROTOCOL.md),
which converts them from dumb pipes into edge caches for free.
Multi-channel capacity multiplication
Section titled “Multi-channel capacity multiplication”The federation section of ROUTING.md does the heavy lifting: different
routers run different lanes on different frequencies simultaneously, and
the backbone merges streams. Within one router, lanes multiplex by schedule.
Net effect: total in-flight capacity scales with (number of routers ×
average lanes), while every member of #pizza still converges on the same
message list — convergence is the sequencer + backfill mechanism, not
shared RF.
A single-radio router can only listen to one lane at a time, so it
time-divides them (LaneSchedule::runtime_decision). A multi-radio router —
Linux lrcd with several SX1262 HATs — can instead park one radio per lane
and listen on all of them at once. LaneSchedule::assign_radios is the
host-tested pure planner that computes that static per-radio assignment from
the same advertised lane plan: given a radio count N and the router’s radio
caps, it returns one RadioAssignment per radio (in_use, lane_index,
lane_id, preset, freq_slot). Radio 0 always holds the anchor lane (P0,
period_s == 0) so the dial tone is never dropped; radios 1.. then cover the
fastest distinct caps-allowed non-anchor lanes, fastest first (preset index ==
speed rank, via preset_allowed), one radio per lane. Surplus radios past the
usable lanes come back in_use == false (parked idle); when N is smaller
than the lane count the uncovered lanes are simply not listed — the caller
leaves them to the anchor radio’s existing single-radio time-division, and the
planner invents no new scheduling. Output is deterministic for a given input
and the helper is allocation-free (fixed kMaxRadios cap, caller-provided
buffer). Like the rest of the lane helpers it computes intent only: the
actual per-radio RadioLib retune stays firmware/RF work and needs human
sign-off (AGENTS.md rule 5); assign_radios authorizes no TX.
DCC burst lanes
Section titled “DCC burst lanes”DCC SEND of a 100 KB file at P3 would take ~5 minutes of monopolized airtime; at P7 it’s ~20 s. The intended choreography, with OFFER/ACCEPT controls currently built:
- IRC gateway intercepts
DCC SENDCTCP (ARCHITECTURE.md), createsDCCCTL OFFERto the recipient through normal routing. - Recipient’s client auto- or user-accepts →
DCCCTL ACCEPT. - The two access routers negotiate a burst lane: the sender’s router
asks each node on the path (
DCCCTL LANE_GRANThop-by-hop, requires Tag8 from each hop’s registration) for an RF capability intersection — each node advertisesfreq_min/freq_max/max_presetin its HELLO caps, so the intersection is computed, not discovered by failure. The builtDCCCTL LANE_GRANTcontrol fixes{stream_id, preset, freq_slot, token(4), ttl_s, airtime_budget_ms}and is delivered reliably to local sidecars. The builtDccBurstGrantTrackerapplies Clock-based TTL expiry, renewal, and matching CLOSE teardown; pure core helpers turn an active grant into a send intent and combine it withLaneSchedule::runtime_decisionto report ready/yield/anchor-fallback runtime state.DccBurstLeaseMachinewraps those helpers into the host-tested lease contract: idle, waiting for a matching window, ready, anchor/mismatch yield, expired, and closed, including token release and a force-minimum beacon-cadence signal while the lease is live. The runtime plan clamps chunks by grant airtime, the usable lane window, and the caller’s cap.DccBurstRfIntentis the pure adapter handoff to hardware: only a ready matching lease maps to concrete frequency/preset radio parameters and a nonzero chunk budget; waiting, yielded, expired, or closed leases preserve control state without authorizing a retune. ThelrcdTCP sidecar now issues a receiver-side grant after ACCEPT, renews it while the receive buffer is incomplete, and uses the active tracked grant on the sender to cap DCCDATA chunks by preset airtime budget. The remaining device work is wiring a board RF driver to that intent; configured firmware lane-plan retuning already uses the same schedule helper for ordinary non-anchor lane windows. - Participants (only the path nodes!) retune for up to
ttl_s(≤ 120 s, renewable), move the stream at burst speed, then fall back to their lane plan. Routers keep their anchor-lane beacon cadence even mid-burst (one beacon per 60 s minimum) so the access network never goes dark; on multi-radiolrcdrouters the burst gets its own radio and nothing pauses at all. - If no common window exists (disjoint band-pass filters mid-path), the
grant falls back to in-plan slots at the bottleneck preset — slower,
never broken. Chunk-level
RESUMEbitmaps (PROTOCOL.md §DCC) make the transfer indifferent to lane changes mid-flight.
Air discipline summary
Section titled “Air discipline summary”- CAD before every TX, randomized backoff on busy (120–480 ms).
- Relay jitter
U(0, 2.5 × airtime). - Per-sub-band duty-cycle token bucket —
lrc::AirtimeLedger(core/include/lrc/airtime.h), a pure sliding-window budget keyed by(lane index, sub-band index). The firmware radio loop consultsadmit(lane, bytes, now, policy, sub_band)before each TX; on a refused TX it CAD-backs off and bumpsCounter::tx_duty_defer, and on a committed TX callscharge(). TheAirtimePolicycarries the sub-band’s regulatory cap in basis points (duty_bp, 0..10000 — EU868 g2’s 0.1% isduty_bp = 10; US915 isduty_bp = 10000, unlimited), andduty_pct_now()/remaining_duty_bp()are the values telemetry and other decision layers (e.g. credit re-origination budgets) read. The host harness exercises the same policy:VirtualRadio::set_ledger()routes simulated TX through the real ledger so duty deferral is validated in CI without radios (docs/TESTING.md). See “Regulatory regions” below for where a sub-band index comes from. - TTL ≤ 7, discovery floods TTL ≤ 3, all rate-limited per origin.
- Every TX decision consults the airtime ledger (TELEMETRY.md) — the same numbers users see, so observed behavior and telemetry can’t drift apart.
Regulatory regions
Section titled “Regulatory regions”lrc::BandPlan (core/include/lrc/presets.h) answers where a lane sits
in frequency space — pure geometry. It carries no power or duty numbers;
those live in a separate, richer engine because a single flat row cannot
express EU868’s five ETSI sub-bands, KR920’s two-tier EIRP, or Japan’s LBT
overlay on a shared frequency plan. docs/research/REGULATORY_SURVEY.md is
the normative source for every number below; a discrepancy between that
document and the code is a bug, not a license to silently pick a value.
Table shape: RegionPlan / SubBand
Section titled “Table shape: RegionPlan / SubBand”core/include/lrc/region.h (hand-written, the decision layer) defines:
struct SubBand { uint32_t start_khz, end_khz; int8_t max_dbm; // regulatory ceiling bool erp; // true: max_dbm is ERP; false: EIRP uint16_t duty_bp; // basis points, 0..10000 (10000 = unlimited)};struct LbtParams { uint16_t cs_time_us; int8_t cs_threshold_dbm; uint16_t max_tx_ms; };struct RegionPlan { const char* name; const SubBand* sub_bands; uint8_t sub_band_count; uint16_t dwell_ms; // 0 = no cap; 400 for US915/AU915/AS923 bool requires_lbt; // legally mandatory, independent of sourced lbt data LbtParams lbt; bool duty_or_lbt_alternative; // EU868/RU864/JP923: either mechanism is legal};core/include/lrc/region_table_gen.h is a generated, committed header
holding the concrete RegionId enum and kRegionPlans[] table — produced
by python3 tools/gen_regions.py from the human-reviewed
tools/regions_table.py. To change a region’s numbers: edit the table,
regenerate, commit both files together. python3 tools/gen_regions.py --check (also runnable as python3 tests/test_region_codegen.py) fails
if the committed header has drifted from the table — the CI-checkable
regen rule, run alongside the rest of the host test suite rather than as a
separate GitHub Actions workflow.
Encoded regions: US915, AU915, EU868 (5 ETSI sub-bands), EU433, AS923-1/-2/-3,
JP923 (Japan’s ARIB overlay on the AS923-1 frequency plan — its own row,
not AS923-1 itself, because the frequency plan is shared but the
channel-access rule is completely different), KR920 (2-tier EIRP), IN865,
RU864 (unlicensed) + RU916 (licensed AS923-3-style overlay — Russia’s two
simultaneous legal regimes, two distinct rows), NZ865, MY919 (2 sub-bands),
CN470. Deliberately deferred regions (AS923-4/Israel’s unverified LBT
mandate, deprecated CN779, several single-sub-band regions with no design
pressure of their own, GB/UK pending its own IR 2030 citation pass) are
listed with reasons in tools/regions_table.py’s module docstring.
Duty cycle: basis points, per sub-band
Section titled “Duty cycle: basis points, per sub-band”AirtimePolicy::duty_bp (core/include/lrc/airtime.h) is basis points
(1/100 of a percent) so EU868 g2’s 0.1% is exactly representable
(duty_bp = 10) without floating point on the radio path. AirtimeLedger
slots are keyed by (lane index, sub-band index)
(region_sub_band_index_for_freq(region, freq_khz) resolves the index from
the frequency the caller is about to transmit on) so a router spanning
EU868’s g1 (1%) and g3 (10%) sub-bands never lets a g3 burst borrow g1’s
separate budget. AirtimeLedger::remaining_duty_bp() exports current
headroom (basis points remaining in the sub-band’s own window, not the
static duty_bp ceiling) for other decision layers — e.g. credit
re-origination budgets — to couple to.
Dwell time: per-transmission, independent of duty
Section titled “Dwell time: per-transmission, independent of duty”US915/AU915 (FCC 15.247(f) FHSS) and the AS923 family cap a single
transmission’s airtime at 400 ms regardless of remaining duty budget —
lrc::dwell_admits(region, airtime_ms) is a hard per-TX ceiling, checked
in addition to the duty ledger, never folded into it (duty recovers over
a sliding window; dwell is a per-transmission legal cap that never does).
LBT: policy objects through the existing CAD seam
Section titled “LBT: policy objects through the existing CAD seam”lrc::lbt_admit(region, LbtObservation) (core/include/lrc/region.h) is a
pure decision function for the firmware’s existing CAD-before-TX seam
(radio.scanChannel() in service_radio()) and the host’s
VirtualRadio/tests/virtual_radio.h equivalent — it does not perform a
carrier-sense scan itself, only judges what the caller already measured.
Required in Japan (ARIB STD-T108: ≥128 µs carrier-sense at −80 dBm, <4 s
continuous TX), Korea (KR920 — LoRaWAN “exclusively uses LBT” per RP002,
but the primary carrier-sense timing was never sourced), and China
(CN470 — LBT AFA + hard 1 s/channel cap). A region with requires_lbt = true but no sourced carrier-sense numbers (KR920 today) fails closed
(LbtResult::DeferUnverifiedParams) rather than silently admitting because
the fields happen to read zero. The observation feeding the decision comes
from the embedder: Node::Config::lbt_observe is the seam a host embedding
wires its carrier-sense pass into, and absent that seam the default
observation fails closed on every requires_lbt region (a node with no
carrier-sense path cannot transmit under an LBT-mandatory regime, by
construction). On firmware, calibrating radio.scanChannel() to an
ARIB-verifiable 128 µs/−80 dBm pass is RF-gated, human-signed work
(AGENTS.md rule 5) — until it lands, JP923/KR920/CN470 are receive-only
on hardware: the firmware gate refuses their TX with
Counter::region_lbt_deferred. EU868/RU864/JP923’s ≤1% exemption use LBT
as a legal alternative to duty-cycling (duty_or_lbt_alternative = true), not a requirement.
EIRP vs ERP + antenna-gain accounting
Section titled “EIRP vs ERP + antenna-gain accounting”Every SubBand tags its ceiling’s unit (erp); ERP = EIRP − 2.15 dB
(sub_band_eirp_ceiling_dbm()), so a table mixing EIRP-denominated regions
(US915, most of AS923/KR920/IN865) with ERP-denominated ones (most of
EU868’s sub-bands) never silently mismatches by ~2 dB. Firmware only
controls conducted (PA output) power; conducted_power_cap_dbm(sub_band, antenna_gain_dbi, cable_loss_dbi) computes the legal conducted-power
ceiling for the operator’s actual antenna — a high-gain antenna must clamp
conducted power down to hold the same EIRP a stock antenna reaches at
full power (RP002’s US915 text makes this explicit for directional gain
over +6 dBi). Antenna gain is a per-node operator-configured value, not a
per-region constant.
Region-before-tx on gate
Section titled “Region-before-tx on gate”Extends the existing ship-OFF invariant (firmware boots receive-only until
an operator opts in — “TX SAFETY” comment, firmware/src/main.cpp): a
region must also be explicitly selected before tx on is honored, so a
freshly flashed board can never transmit under an unspecified regulatory
regime. lrc::region_gate_check(RegionGateState) is the pure decision;
firmware’s console region <name> command sets FwConfig::region
(persisted, empty by default), and tx on refuses with
Counter::region_unset_refusal when no region resolves. region with no
argument reports the current setting (or that it’s unset/unrecognized).
Enforcement: where the gate lives, and drop-don’t-queue
Section titled “Enforcement: where the gate lives, and drop-don’t-queue”The engine above is ENFORCED, not advisory, at exactly one choke point per embedding:
lrc::Node—emit_radio_frame()(core/src/node.cpp), the single radio egress point every fresh TX, relay, and store-and-forward flush passes through, consultsradio_tx_admitted(): sub-band resolution (out-of-band → refuse,region.out_of_band), dwell (region.dwell_clamped), LBT where mandatory (region.lbt_deferred/region.lbt_admit), then the per-sub-band duty ledger (tx.duty_defer, charged on admit). The embedder arms it withNode::set_region_tx_context()— region plan, current TX frequency/preset/lane, duty window. No context (the default) = no enforcement: a TCP-only daemon or a host test makes no legal claims about spectrum it never keys. TCP egress is never gated — no RF law applies to a wire.- Firmware —
region_tx_admitted()in the radio loop’s TX branch (firmware/src/main.cpp), judged against the lane the radio is parked on at actual-TX time, with the region resolved from the operator’s savedregionsetting. The firmware does not also set Node’s context: the lane can retune between Node queueing a frame and the frame airing, so queue-time accounting would charge the wrong lane — exactly one layer per embedding owns enforcement (the double-charge rule, documented atNode::RegionTxContext).
Refused frames are dropped, with the reason counted — never queued. The rejected alternative was a bounded/TTL’d deferred-frame queue in front of the radio (“hold the frame until budget frees up”). Rejected because:
- Starvation outlasts any RAM-bounded queue. A duty window is regulatory-hour-scale: EU868 g1 at 1% is 36 s of airtime per hour, g2 is 3.6 s. A saturated cell starves for minutes — a queue deep enough to ride that out is megabytes of PSRAM (already budgeted by the Arena for message stores, on hardware where every KB is contended), and a queue shallow enough to afford overflows immediately, which is a drop with extra steps and a stale-data tax.
- The transport already owns recovery. Sequenced channel traffic heals through CHANSYNC gap backfill (a receiver that missed seq N requests it when capacity returns); DMs and admin ops re-send through the pending-ack retry machinery (each retry re-enters the gate and passes once the window rolls); offline delivery is the mailbox’s job; PATH relay frames already have their own bounded store-and-forward. A deferred-TX queue would be a second, competing retry layer with its own staleness and ordering bugs, in front of machinery that was designed for exactly this.
- Stale frames waste the budget that starved them. When a g-band window finally rolls, spending the freed airtime on an hour-old chat message (that CHANSYNC would have healed anyway, deduped at every receiver) is strictly worse than spending it on live traffic.
A duty-starved EU868 router under sustained load therefore sheds load at
the gate (visible as tx.duty_defer in stats and the observatory’s
airtime view), keeps its budget for what the recovery layers actually
request, and resumes normal service the moment the window rolls —
behavior pinned by tests/scenarios/eu868_duty_starve.scn (sim), the
region_node_gate_* tests (Node gate), and the firmware build.