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:
- 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.
- 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,
AirtimeContextinpericulum/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:
- Red under the lawful limit is honest: the design exceeds the band's budget, and the result says exactly that.
- Green with a declared lifted limit is honest: it measures the stack, not the law, and every reader can see which.
- 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_lockedis true, a frame whose age since the interface accepted it exceedsleviculum_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 sfor 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.
lnsddriving 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 withLORA_TX_STALE iface=<name> age_ms=<n> len=<n> held_ms=<n>and counted astx_stale_drops(also insidetx_queue_drops), whichlnstatusshows asTX 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 aLORA_QUEUE_DROP suppressed=<n> window_ms=<n>line for what a clipped window held back, and the running total is thelora_stale=field on the periodic[TRANSPORT]line. The counter is how the fix is read on the air:lrproof_no_linkon a relay should fall toward zero for through-traffic aslora_stalerises.
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 asANN_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 itsannounce_caprelays no announce throughTransport.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] … holdingline — and lnsd emitsDUTY_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 readsduty_holdfalse 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 inleviculum-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:
- 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. - lnsd driving an RNode. lnsd's RNode interface watches the modem's
CMD_READYflow control, and a gate closure that qualifies as a hold (DutyHolds::holding) raises the flag. - 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_HOLDframe 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 sameDUTY_HOLDline 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 measuredduty_hold=never ann_suppressed=0on 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
- Interface Isolation — why airtime backpressure is host-side and per-interface while airtime enforcement is firmware-side.
- Python-RNS Compatibility — the deviation rule that lawful-by-default satisfies.
- Evidence and Honesty in Testing — the wider discipline behind "say so in the output".