Regulatory Airtime

Unlicensed LoRa bands are shared under duty-cycle rules. This page records where the limit is enforced, what a node does when nobody configured one, why no radio setting is ever refused for a regulatory reason, what it takes to switch the limit off, and one measurement pitfall. It is a durable rule for every radio firmware we write, present and future.

Enforcement belongs in the firmware, not the host

The firmware is the only place that knows what actually went on the air: retransmissions, preambles, frames queued by a host that has since crashed — none of that is visible from above. A host-side budget can shape traffic, but only the modem firmware can enforce a duty cycle, because only it stands between the queue and the antenna.

The RNode firmware is the model: it accounts every transmitted frame's airtime into rolling bins, raises airtime_lock when the short- or long-term limit is exceeded (reference/RNode_Firmware/RNode_Firmware.ino:1673-1675), and gates the transmit queue on it — if (!airtime_lock && queue_height > 0) (RNode_Firmware.ino:1624). The limits arrive from the host as CMD_ST_ALOCK / CMD_LT_ALOCK (Framing.h:36-37), but the enforcement never leaves the device.

Our LNode firmware enforces the same way: AirtimeTracker (leviculum-core/src/rnode.rs:1832) mirrors the RNode ledger, and the nRF TX path holds a queued frame instead of keying the radio while the tracker is locked (is_locked, leviculum-nrf/src/lora.rs:1902-1989), continuing to listen so RX is not starved — until the frame has waited so long that keying it would be pointless, which is the age rule below.

The host-side airtime credit bucket (leviculum-std/src/interfaces/airtime.rs, see Interface Isolation) is backpressure, not regulation: it keeps the serial queue from absorbing minutes of backlog. It is a comfort for the stack, not a legal control, and nothing may treat it as one.

Lawful by default

A node that is not told otherwise obeys the band it is on. When no airtime_limit_long is configured, the host derives the lawful long-term limit from the TX frequency (resolve_lt_alock, leviculum-std/src/driver/mod.rs:513-547) and sends it to the modem; a standalone LNode whose host never sent one derives it in the firmware from its own frequency (firmware_default_lt_alock, leviculum-core/src/rnode.rs:1638). Both read the same table, etsi_eu868_duty_cycle (leviculum-core/src/rnode.rs:1524), which carries the EU 863-870 MHz sub-bands with their 0.1 % / 1 % / 10 % duty cycles and the 433.05-434.79 MHz band at 10 %. An explicit configured value always wins — including an explicit 0, which the firmware reads as unlimited.

A cap that cannot be read back is not a cap anyone can check. The firmware states the settings it applied and the limits it loaded into the tracker on the boot-critical log path — the one that bypasses the debug port's runtime drain gate (airtime_limits, leviculum-nrf/log-line/src/facts.rs:421) — and states them again on every runtime reconfiguration. Until 2026-08 both were ordinary runtime lines: a board that came up before a reader attached dropped them with everything else, so the two facts a compliance question is actually about were the two that could never be obtained from a running board. Neither is recoverable any other way — the settings live in the radio's registers and the cap in the airtime tracker, and nothing reads either back out.

The limits line is unconditional, and it names an origin per limit. It used to be emitted only when the firmware had derived the cap itself, which left the more dangerous case silent: a host that sent an explicit 0 switched the cap off and produced no line at all, so the cap in force had to be inferred from an absence, and a board legitimately unlimited on a shielded bench read exactly like one unlimited in the field. It now carries both limits with the raw u16 and a human rendering (lt_cap=unlimited versus lt_cap=0.10% — the one confusion on this line with a legal consequence), whether each came from the host or was derived, and the lawful cap the frequency alone would give, so a host's choice can be weighed against the band without looking a sub-band up in this page. grep AIRTIME on a fresh boot answers "under what cap is this board transmitting, and who chose it".

Every row of the table has been verified against the standard text: ERC Recommendation 70-03, Annex 1, sub-bands h1.3-h1.9 for 863-870 MHz and the 433.05-434.79 MHz entry of the same annex. The duty cycle is also the only compliance route open to a fixed-frequency LNode: every sub-band's requirement reads "≤ x % duty cycle or LBT+AFA", and AFA — adaptive frequency agility, changing channel — is impossible here by construction.

One honesty note, deliberate: the table covers only the bands above. Other bands (US 902-928, AU/NZ, ...) have no citable source in this tree, so they get no auto-limit and a warning that says so — a limit invented from memory would read as authoritative to exactly the operator who most needs it not to be. Supply the citation and the table grows.

TX power follows the same lawful-by-default shape (resolve_tx_power capped by lawful_erp_dbm, leviculum-core/src/rnode.rs:1570): an absent txpower asks for the board maximum, capped by the sub-band's e.r.p. limit — 25 mW everywhere in the European SRD spectrum except 500 mW in 869.4-869.65 MHz and 10 mW in 433.05-434.79 MHz. An explicit txpower wins even above the cap (the operator may hold a licence or know the jurisdiction); the excess is logged. The narrowband bands between the wideband sub-bands (868.6-868.7 MHz and its four siblings, alarms, ≤ 25 kHz channel spacing) fit no LoRa bandwidth this stack configures, so a carrier that overlaps one is warned about by name at interface build (erp_band_gap, leviculum-core/src/rnode.rs:1513) — falling through to "no known limit, board maximum" without a word would be the most permissive outcome exactly where the operator most needs to be told. The carrier is then honoured; see No radio configuration is refused below.

Python-Reticulum does not do lawful-by-default; the cap only shapes local TX and is invisible to receivers, so this is a Priority-1 enhancement under the deviation rule.

No radio configuration is refused

Every radio setting this stack is given is honoured. A setting that looks unlawful for a region is warned about, loudly, by name — and then applied. That is project policy, decided 2026-08-16, and it supersedes the hard band-gap error this page used to describe.

Two reasons, and the second is the stronger one:

  1. The jurisdiction is not knowable from here. The same carrier is lawful under a licence, in another region, on an amateur allocation, or in a shielded chamber with dummy loads. A check that reads a frequency cannot tell those apart from an unlawful deployment, so it would refuse the lawful cases too.
  2. The operator is the responsible party. In the EU it is the operator, not the software author, who answers for compliant operation. Software that refuses a setting takes on a responsibility it does not hold, and hands the operator a daemon that will not start instead of the information they need. Our job is to make the consequence impossible to miss, not to make the choice.

The warning is emitted at WARN, never at debug: a decision narrated below the default log level is the silent substitution this policy exists to prevent.

What stays a refusal is anything with no regulatory content in it — the SX1262's 150-960 MHz tuning range, the ten bandwidths the modem has a register code for, the 0..=37 dBm field of the RNode wire protocol, the SF and CR ranges shared with Python-RNS, and the SoftDevice version guard that keeps a flash from bricking a board. Those are arithmetic and device protection, not paternalism: they describe what the hardware can be asked for at all, and honouring them is not a judgement about anybody's licence.

Prose alone has drifted twice here — the code once, this page once — so both halves are mechanical now. The code is pinned behaviourally by no_radio_configuration_is_refused_for_a_regulatory_reason (leviculum-std/src/driver/interface_build/mod.rs:690), which drives the known regulatory edge cases through the config-building entry point and asserts each one builds and warns at WARN, with a second half pinning the capability refusals so the first cannot be satisfied by deleting every check. This page is pinned by the_book_describes_the_band_gap_as_a_warning_never_a_refusal (leviculum-std/tests/doc_radio_policy.rs:198).

Disabling is an operator act, not a test convenience

Switching the limit off is sometimes legitimate — a shielded bench with dummy loads, a throughput scenario that cannot measure what it exists to measure at 1 % duty. But it is an operator decision with a paper trail, never a default and never a convenience:

  • It requires a written justification. Periculum's [disable_airtime_lock] section refuses to parse without one (periculum/src/topology.rs:258, DisableAirtimeLockDef).
  • Every run that had the limit off must say so — in its terminal output (the airtime banner prints the rendered limit per frequency, whichever route produced it) and in its result document (the measurement cell records the policy, the rendered limit, and the lawful limit for that frequency, AirtimeContext in periculum/src/bench.rs, with the source — scenario or rig — recorded in the results).

Of the three possible outcomes, a silent green under a lifted limit is the worst:

  1. Red under the lawful limit is honest: the design exceeds the band's budget, and the result says exactly that.
  2. Green with a declared lifted limit is honest: it measures the stack, not the law, and every reader can see which.
  3. Silent green under a lifted limit is a lie with a green checkmark: it reads as evidence that the system works lawfully when it never once ran under the law. It also poisons comparisons — a figure taken with the lock off next to one taken with it on is a comparison of the lock, not of the stack — and it ships that lie forward into every document that cites the run.

Where the bench-level switch lives, and why

The blanket switch for a whole bench lives in the Periculum rig profile (rig.toml, periculum/src/rig.rs) — site data, not scenario data. Containment is a property of the site: whether the bench is shielded and on dummy loads is true of THIS rig, not of a scenario file that travels between benches and operators. A scenario that must not run unlimited even on such a bench can carry [require_airtime_lock], which wins. The mechanics are Periculum's to document; the durable rule here is only the split: scenario files describe the experiment, the rig profile describes the site, and the airtime carve-out belongs to the site.

The measurement pitfall: reading the meter restarts it

The duty-cycle history lives in RAM, and on ESP32 targets the RNode firmware's startRadio() zeroes it: it calls init_channel_stats() (RNode_Firmware.ino:523), which clears the airtime bins and both utilisation figures (reference/RNode_Firmware/Utilities.h:1858). So a diagnostic that starts (or restarts) the radio in order to read the airtime counters measures nothing — the act of taking the reading destroyed the reading. We hit this in practice.

The general lesson is not radio-specific: a diagnostic must not disturb what it measures, and a diagnostic that can must be checked for it before its numbers are believed. See Evidence and Honesty in Testing.

A hold ages traffic; it does not thin it

The gate above holds the frame at the head of the queue and re-checks. That is FIFO under a hold, and the consequence is worth stating plainly for anyone reading a LoRa capture: a duty-cycle hold does not thin traffic, it ages it. Each dip of the ledger below the cap admits exactly one frame, which re-pins the lock, so under sustained load the queue drains at the cap's rate with its order intact and the frames that reach the air are as old as the standing backlog. Nothing is lost and the airtime stays lawful — but what the lawful airtime carries is history.

Measured on the WisMesh Pocket V2 during the field test of 2026-09-27 (docs/measurements/2026-09-27-field-test-lora-chain-columba.md, outside the book because it is a measurement record, not a rule): pinned at a 10 % long-term cap, one frame left every 10–15 s and the three relayed link requests in the window were keyed 145.6 s, 137.7 s and 144.0 s after the stack handed them over. A forwarded link request is routable only until the relay's link-table entry expires, (hops + path_hops + 2) × 6 s — 30 s in that topology — so every proof came back to an entry that had died about 115 s earlier.

So the interface drops what it has held too long, at the point where it waited (Codeberg #433):

  • The rule. At dequeue, while AirtimeTracker::is_locked is true, a frame whose age since the interface accepted it exceeds leviculum_queue_budget::HOLD_MAX_AGE_MS (18 s) is thrown away instead of keyed. The 18 s is derived, and the derivation is in that constant's own doc comment: it is the shortest entry any relay grants, (1 + 0 + 2) × 6 s for a one-hop request to a destination the relay reaches directly, so a frame older than that is dead on arrival in every topology; it sits above the 15 s short-term airtime window, so a lock that engaged on short-term airtime alone never loses a frame it was about to release, and below the measured field topology's 30 s entry deadline minus the measured 1.0 s return leg.
  • Both stacks. lnsd driving an RNode cannot read the modem's lock, so its send queue applies the same constant against what it can see: the CMD_READY gate held shut, with frames waiting, for longer than a full frame's airtime plus one re-query interval explains (DutyHolds, in the same crate). A frame past the cap whose wait overlapped such a hold is dropped at dequeue with LORA_TX_STALE iface=<name> age_ms=<n> len=<n> held_ms=<n> and counted as tx_stale_drops (also inside tx_queue_drops), which lnstatus shows as TX stale. Frames the modem already holds age inside it, out of the host's reach.
  • Only under the lock. A frame that waited for CSMA, for an acquisition-jitter draw or behind a burst gap is not stale in this sense, whatever its age: those waits are the interface's own and end by themselves. Only the regulatory lock holds a frame for minutes.
  • Type-blind. The interface reads the age and never the packet, so a link request, an announce and a resource chunk of the same age get the same verdict (Interface Isolation). What makes the drop acceptable is not a judgement about the packet but the cadences above it: a link request is reissued every 6 s per hop, an announce on its own cadence, LXMF at its own layer — a frame older than 18 s behind a lock has been superseded or written off by its sender already.
  • Counted, never silent. Every drop raises LORA_QUEUE_DROP reason=stale age_ms=<n> bytes=<n> total=<n> under the [LORA] prefix, rate-limited like the core's [DROP] lines with a LORA_QUEUE_DROP suppressed=<n> window_ms=<n> line for what a clipped window held back, and the running total is the lora_stale= field on the periodic [TRANSPORT] line. The counter is how the fix is read on the air: lrproof_no_link on a relay should fall toward zero for through-traffic as lora_stale rises.

What this does not do is create airtime. The cap spends the same milliseconds it spent before; the change is that they carry current traffic instead of fossils. A board that is permanently over its cap is still a board with too much to say, and the honest reading of a climbing lora_stale= is a load problem, not a solved one.

A relay in duty hold advertises no route — and the hold provably lifts

The same arithmetic binds the control plane (leviculum#493, decided on #433). A relay whose interface is in duty hold cannot carry a link setup under any stack's clocks — initiator 20 s, forwarding entry 24 s ours and 12 s Python's, against two held frames of up to 36 s — so an announce it relays there advertises a service it cannot render, and every initiator behind it spends 20 s per attempt learning that. The rule has two halves, and the second is the guard on the first:

  • While the hold is in force, the interface advertises nothing. The interface reports its hold to the core as a boolean (duty_hold, mirrored like the online flag — the core computes nothing), and the core's announce admission refuses rebroadcasts and path responses alike on a held interface, logged as ANN_TX_SUPPRESSED … closed=duty_hold. The refusal is final, not queued: the destination re-announces on its own cadence, and a queue released at lift time would key routes exactly as old as the hold. The node's own announces are exempt — the #402 announce cap governs them, priced against the duty budget by #401 rule 5. Python is the precedent for the half that refuses: an interface over its announce_cap relays no announce through Transport.outbound() at that moment (Transport.py:1252-1294) — though Python queues what we refuse, which is the one place we deviate, per the stale-route argument above.
  • The hold ends when the rolling window frees budget, visibly. The firmware states the edge once — [LORA] hold lifted lt_ms=<n> held_for_ms=<n> dropped_stale=<n> beside the per-turn [LORA_AIRTIME_LOCK] … holding line — and lnsd emits DUTY_HOLD iface=<name> state=held|lifted lt_ms=<n> on the same two edges. The lift is pinned by host tests on the real ledger: flat at the cap at t0 it holds, nothing inside the rolling hour frees it, and the first bin that leaves the hour drops the long-term sum below the cap and reads duty_hold false again, also when the loop was parked in a receive window across that edge (leviculum-nrf/queue-budget/src/tests.rs, and the end-to-end mvr in leviculum-std/tests/mvr/). That holds because the ledger retires every bin a call skipped, not only the bin after the current one as the reference does (RNode_Firmware.ino:688, :698): a bin no call landed on would otherwise keep an hour-old charge and hold the lock on air spent in the previous hour (leviculum#495). The wire sees none of this.

Three topologies set that boolean, and each from the layer that owns the carrier:

  1. The board as a node. The firmware's own core reads the lock gate's flag (LORA_DUTY_HOLD) on every main-loop wake and mirrors it onto its LoRa interface.
  2. lnsd driving an RNode. lnsd's RNode interface watches the modem's CMD_READY flow control, and a gate closure that qualifies as a hold (DutyHolds::holding) raises the flag.
  3. lnsd driving an LNode over its serial protocol (leviculum#501). The board's lock gate holds, but the host's interface is a plain serial port with no flow control, so the board tells it: a DUTY_HOLD frame on the USB control envelope on each edge, and the current state behind every media report, which lnsd asks for each time the port attaches. lnsd's serial interface sets the flag from it, logs the same DUTY_HOLD line as the RNode path, and clears it when the port goes down. Before this, a relay whose modem was locked for 261 s of a 310 s run measured duty_hold=never ann_suppressed=0 on the host (497): the board knew, and the protocol between board and host had no word for it. The frame is USB protocol between our board and our host (docs/src/firmware/usb-control-envelope.md), never on the air.

A propagation node leaves half its budget to forwarding

The hold above is the end state; the second half of the same decision (leviculum#494, Lew 2026-10-07) keeps a propagation node from walking into it on its own traffic. The field relay of 2026-09-27 was a propagation node: its own sync rounds filled the lawful budget, the hold followed, and the link setups of everyone behind it died there (#433). So a board running the propagation role originates nothing once the rolling hour has spent half its long-term cap (OWN_TRAFFIC_SHARE_PERMILLE, 500, in leviculum-nrf/queue-budget/src/lib.rs). The split is by origin, and it is decided where the traffic is originated, not where it is keyed: the LoRa queue still treats every frame alike, and what the node forwards as a transport node never meets the share, so forwarding always has the other half.

The engine asks before it starts a sync round, before it identifies and offers on the round's link, before it sends the transfer the peer asked for, before an active delivery, and before it accepts an inbound offer, which past the share is answered with the reference's own postponement, ERROR_THROTTLED, on which a stock peer waits and offers again. Answers to clients (/get, upload proofs) are not its own initiative and are not gated; its announces are priced against the budget by #401 rule 5. A refusal is stated once, on its edge, as PN_YIELD reason=duty_share lt_ms=<n> cap_ms=<n> share=<permille> site=<first site>, and every site retries on its own cadence. The used figure is the LoRa task's ledger, published once per loop turn.

The firmware ledger is not a cross-session account

The same fact has a second consequence, and it is the one that decides where an hour-scale budget lives. Because a radio start clears the bins, the firmware's long-term figure covers airtime since the last radio start, not the rolling hour. It is a lower bound, and the bound is zero exactly when the question is worth asking: a harness that reboots a board to give a test a defined starting state (Periculum does, before every scenario that binds one) has zeroed it, and the daemon under test zeroes it again when it brings the radio up. An offline radio's history survives in RAM and cannot be read out-of-band at all — the only way to make the firmware emit it is the call that clears it first.

So: enforcement belongs to the firmware, but the hour-scale account belongs to whoever drives the radio. The board is the only thing that can refuse to transmit, and the only thing that cannot tell you what it transmitted an hour ago. Anything that needs to know — a test harness spacing its runs, a scheduler shaping traffic — keeps its own ledger and states plainly that the figure is modelled, not measured, and a floor rather than a total. A reset makes the board forget what it radiated; it does not make the airtime unspent.

See also