Testing
LoRa Relay Chat is designed so that everything except the literal act of radiating RF power is testable on the host — no boards required. The docs are the spec, the tests are the law, and the whole-network behavior runs in CI in a few hundred milliseconds. This file documents the tiers, what each one covers, and — just as importantly — exactly what still needs a bench.
The tiers
Section titled “The tiers”| Tier | Where | What it covers | Runs in CI? |
|---|---|---|---|
| Unit / pure policy | tests/test_*.cpp (crypto, packet, lanes, reg, routing_policy, return_path, peer_link, …) |
One component in isolation, vector-driven | ✅ |
VirtualRadio |
tests/virtual_radio.h + test_virtual_radio.cpp |
The simulated multi-lane PHY: lanes, spreading factor, SNR/range budgets, airtime/duty, lane contention | ✅ |
ChaosNet |
tests/test_chaos.cpp |
N-node federation over a shared medium: loss/latency/dup/reorder, partitions, reboots, CHANSYNC gap healing | ✅ |
lrcsim scenario testbed |
sim/, tests/scenarios/*.scn, tests/test_sim_*.cpp |
Whole-network scenarios with geometry, mobility, and fault injection; see LRCSIM.md | ✅ (.github/workflows/scenarios.yml) |
| Daemon end-to-end | tests/smoke_lrcd.py |
Real sockets, real lrcd instances on loopback, real IRC clients |
✅ |
| WS-SIM Mode B (multi-process) | sim/lrcbridge.cpp, tests/smoke_modeb.py |
Real lrcd processes federating over simulated RF (UDP) through the same field model Mode A uses; --state-dir persistence across real SIGKILL, mixed TCP+RF topologies — see §Mode B below |
✅ |
| Firmware size-budget | tests/check_firmware_size.py ENV |
PlatformIO build + flash-size parse against the 2 MB OTA slot budget — no hardware needed | ✅ |
| Fuzz (radio parse) | tests/fuzz/decode_fuzz.cpp |
libFuzzer over decode() + decode_identity_record() — adversarial parse inputs (off by default, -DENABLE_FUZZ=ON) |
✅ (CI) |
| RF bench | human-gated, AGENTS.md rule 5 |
TX power, regulatory airtime on actual silicon, antenna/range, board-level actuation | ❌ — needs you |
The first four are all driven by ./build/lrc_tests and python3 tests/smoke_lrcd.py from the build-and-verify loop in AGENTS.md.
The seam the whole thing hangs on
Section titled “The seam the whole thing hangs on”A Node exposes three emit seams and two ingest paths:
std::function<void(const uint8_t*, size_t)> out_to_peers; // TCP/federationstd::function<bool(RouterId, const uint8_t*, size_t)> out_to_peer; // directed federationstd::function<void(const uint8_t*, size_t)> out_to_radio; // RF lane ← VirtualRadiovoid on_peer_frame(const uint8_t*, size_t); // federation ingestvoid on_radio_frame(const uint8_t*, size_t); // RF ingest ← VirtualRadioOn a board, the firmware’s SX1262 driver + the CAD-before-TX loop sit behind
out_to_radio (firmware/src/main.cpp), and the radio ISR delivers to
on_radio_frame. On the host, a function call replaces the air: ChaosNet
wires out_to_peers to an in-process delivery queue, and VirtualRadio wires
out_to_radio to a simulated PHY that applies the same lane/SF/airtime policy
the firmware’s LaneSchedule/airtime_ms would. The Node under test is the
same compiled code that ships in firmware.
What VirtualRadio faithfully replays
Section titled “What VirtualRadio faithfully replays”It is test-only code under tests/; it never ships in firmware. It uses the
same shipped policy the firmware uses, so the two can’t drift:
- Lane scheduling — the caller tells it which lane a node is parked on; a
frame is heard only by listeners on the same
(preset, freq_slot). - Spreading factor — a lane’s preset fixes one
(SF, BW, CR). A frame is received only where the per-link SNR clears that preset’s probe floor (kPresets[].probe_snr_x10). This is the primitive behind “the slow preset reaches where the fast one can’t.” - Airtime / duty cycle — every TX consumes
airtime_ms(preset, bytes)from a per-node rolling budget; exhaustion defers exactly like the firmware’s CAD loop would. (When Phase 1’sAirtimeLedgerlands in core,VirtualRadiodelegates to it; the model stands alone today.) - Lane contention — two TXs in the same slot on the same lane collide.
- Multi-radio listeners — a router parked on several lanes via
LaneSchedule::assign_radios()hears each one concurrently. - Geometry (optional) —
(x, y, height)per node yields a log-distance SNR so “router on a hill reaches a hidden client on SF5, but the client only returns on SF10” is expressible without antennas.
What it deliberately does not model: crystal drift, capture effect, fading multipath, and radiated power — those only answer to a bench.
Mode B: real lrcd processes over simulated RF
Section titled “Mode B: real lrcd processes over simulated RF”lrcsim Mode A (above) proves in-process lrc::Node logic against a virtual
clock. Mode B (docs/research/SCENARIO_TESTBED.md §2, docs/LRCSIM.md §8) is the
complementary unix-native multi-process bed: REAL lrcd binaries, spawned as
real OS processes, exchanging simulated RF frames through lrcbridge — so the
things Mode A’s virtual clock and in-process Node calls can’t exercise get
covered: the actual daemon binary and its poll() loop, --state-dir
persistence across a real SIGKILL (not a C++ object destructor), and mixed
TCP-peer + simulated-RF topologies. CI runs a small, bounded Mode B smoke set;
the full scenario matrix stays Mode A’s job (fast, deterministic, seed-driven).
The seam: lrcd --rf-udp
Section titled “The seam: lrcd --rf-udp”lrc::Node exposes exactly one radio ingress/egress pair
(docs/TESTING.md “The seam the whole thing hangs on”, above):
out_to_radio / on_radio_frame. On a board, firmware’s SX1262 driver sits
behind it; in Mode A, VirtualRadio/sim::Runner does. lrcd --rf-udp HOST:PORT wires the SAME seam to UDP datagrams exchanged with a lrcbridge
process instead — no other daemon behavior changes, and no socket code
was added to core/ (AGENTS.md rule 2: the daemon owns the socket, Node
still only ever sees out_to_radio(bytes) / on_radio_frame(bytes)).
Because every frame Node emits (fresh TX, relay, RTRSYNC, CHANSYNC replay)
already flows through emit_radio_frame() unconditionally
(core/src/node.cpp), wiring this one seam is the ENTIRE daemon-side glue
Mode B needs — a lrcd started with no --peer at all still fully
federates (directory, channel chat, DMs, RTRSYNC) once two such processes
share a lrcbridge. This was proven end-to-end while building this seam:
two peer-less lrcd processes exchanged channel chat purely over
--rf-udp, and a manually-inserted 100 km link correctly delivered nothing
(the field model’s floor gate, not a stub).
Flags: --rf-udp HOST:PORT (the lrcbridge address; absent = historical
behavior, out_to_radio unwired), --rf-node-id N (0..255 — this process’s
stable wire identity; independent of --rtr, since an RF participant need
not be a router), --rf-preset 0..7 and --rf-freq-slot N (this process’s
starting lane; default anchor/0, matching every other lane-placement default
in the codebase).
The datagram header
Section titled “The datagram header”One UDP datagram is one on-air frame (matching the LoRa packet-radio
abstraction the rest of the codebase assumes — one send() per transmission,
no stream framing beyond UDP’s own datagram boundary):
u8 magic 0xC5 — rejects stray traffic before it's mistaken for a malformed LPP frameu8 version 1 — bumped on any incompatible header changeu8 msg_type 0 = DATA, 1 = TUNEu8 node_id sender's --rf-node-idu8 preset sender's CURRENT lane preset (lrc::kPresets index, 0..7)u8 freq_slot sender's CURRENT frequency slotu16 payload_len network byte order; 0 for TUNE (no body follows — the header above IS the tuning announcement)[payload_len bytes] opaque LPP frame bytes (DATA only)A TUNE datagram is sent once at daemon startup so lrcbridge learns a
listener’s lane even if it never transmits (a pure receiver still needs the
bridge to know where it’s listening), and again whenever /msg *lrc rf retune (below) changes this process’s own lane mid-run. Every inbound
datagram (DATA or TUNE) refreshes lrcbridge’s learned (source address, node_id, preset, freq_slot) for that sender — there’s no separate
registration handshake, matching how a real radio medium doesn’t require a
listener to “log in” before it can be heard.
Counters (docs/TELEMETRY.md): rfudp.tx / rfudp.rx / rfudp.drop /
rfudp.tune_tx / rfudp.tune_rx / rfudp.retune / rfudp.retune_fail.
Mid-run lane retune: /msg *lrc rf retune
Section titled “Mid-run lane retune: /msg *lrc rf retune”The single-radio frequency-agility story: one radio that retunes mid-run,
reaching the daemon rather than staying a scenario-file-only lane placement.
lrcd has no CLI/admin path over the wire for this (unlike admin rtN set ..., docs/USERGUIDE.md “Remote node administration”) because there is
nothing to route: the --rf-udp seam this retunes is a daemon/main.cpp
construct (RfUdpSeam) with no remote-router counterpart to sign a request
for. Instead it’s a daemon-local *lrc service verb, authorized the same
way IRC OPER power already is (Node::is_oper() — the connecting session’s
IRC USERNAME must itself be a 16-hex-digit UID listed in --admin-operator,
docs/USERGUIDE.md’s “one identity per IRC username” convention):
/msg *lrc rf retune <preset 0..7|PN> <freq_slot 0..255>On success this does three things in order: (1) validates the preset id and
frequency slot (rejecting anything parse_preset_span/parse_u8_span
wouldn’t accept — a bare index, or P-prefixed like lane.plan’s own lane
grammar, and 0..255 for the slot); (2) updates the daemon’s own announced
tuning and re-sends the TUNE datagram to lrcbridge, exactly the
one-shot-at-startup announcement above, just triggered again; (3) calls
Node::set_relay_preset() so AC credit routing immediately prices relays
at the new preset — deliberately NOT set_routing_distance_context(), which
would also pin this node’s rdist (node.h’s own documented distinction: a
lane retune is not a claim about hop distance, and pinning rdist on a
retune-only path would fight the passive router-distance learner for every
future embedder that only wants the airtime-pricing half). A node started
without --rf-udp reports rf retune denied: unsupported; an unauthorized
session reports rf retune denied: not an admin operator; a failed
bridge send reports rf retune failed: ... and leaves the previous preset in
effect (no partial state — set_relay_preset() is only called after the
seam confirms the retune actually went out).
lrcctl radio (below) surfaces rfudp.tune_tx/rfudp.tune_rx and the new
rfudp.retune/rfudp.retune_fail counters alongside the existing rfudp.*
funnel, so an operator watching a running node sees a retune land without
needing to grep /metrics by hand.
lrcbridge
Section titled “lrcbridge”sim/lrcbridge.cpp is the broker: a UDP server that, on each DATA datagram,
decides which OTHER known peers hear it — reusing sim::Field
(sim/field.h) exactly as Mode A’s sim::Runner does, so a Mode B run’s
propagation is byte-for-byte the same model (not necessarily the same
numbers, since Mode B runs on a real wall clock, not a virtual one) as Mode
A’s. Single-tuner is the baseline truth: a listener hears a transmission
only if its last-announced tuning exactly matches the sender’s (preset, freq_slot) AND the field-model SNR between them clears that preset’s probe
floor (sim::Field::clears_floor — the same admission test VirtualRadio/
Mode A use).
cmake --build build -j --target lrcbridge./build/lrcbridge --listen 127.0.0.1:9500 --config topology.scn \ --control-udp 127.0.0.1:9501 --trace out.jsonl --verboseThe --config file is a SUBSET of the .scn grammar (sim/scn.h), parsed
with the real parser (no forked grammar): seed (required by the parser),
field pathloss/shadow/..., node <id> [x= y= | grid=] [z=] [tx_dbm=], and
link <id>-><id> snr=<dB>|off. A node’s NAME in this file is the decimal
wire node_id a peer’s --rf-node-id sends (e.g. node 1 x=0 y=0) — the
only stable per-sender key available on the wire (a UDP source port isn’t:
it can change across a daemon restart). Without --config, every link is an
open link (clears every floor), matching sim::Field’s own default for a
scenario node without geometry — so lrcbridge --listen H:P alone is a
valid “just relay everything whose lane matches” bridge.
Runtime fault injection — the control channel (deliverable 2’s requirement)
— accepts line-oriented commands over stdin (when the process is started
without --control-udp, or always) or --control-udp datagrams, using node
ids from the same config-file identity space:
partition <a> <b> # cut the link both directionsheal <a> <b> # restore itsnr <a> <b> <dB> # pin a directional override (matches .scn `link`)snr <a> <b> off # pin "never hears" (matches .scn `snr=off`)snr <a> <b> clear # remove a live override, fall back to the config # file's own field/link baselinelrcbridge --trace
Section titled “lrcbridge --trace”--trace FILE (or --trace - for stdout) emits one JSONL event per line
using the SAME schema and sim::TraceWriter/sim::JsonlEvent machinery Mode
A’s lrcsim --trace uses (sim/trace.h, docs/LRCSIM.md §6) — deliberately
not a forked format, so chirpscope replay and any other trace tooling reads
both modes’ output unmodified. Because Mode B has no virtual clock
(SCENARIO_TESTBED.md §2), the envelope’s mono_ms is real wall-clock
milliseconds since the bridge started (the writer still stamps
clock_kind:"virtual" — that’s the schema’s own field/value, shared verbatim
rather than forked for a real-time producer). The trace file is
line-buffered so a SIGTERM/SIGKILL-style teardown (this process runs
forever otherwise; smoke_modeb.py’s own cleanup convention) never loses the
last few lines to libc’s full-buffering-for-a-regular-file default.
Every datagram this bridge receives emits exactly one of:
packet_rxper listener that heard it — same fields as Mode A’spacket_rx(node,from, packet-header set,payload_len,snr_x10,preset,freq_slot).sim_dropper listener/attempt that didn’t, withreasonone oflane_mismatch(tuning didn’t match),below_floor(SNR below the preset’s probe floor),partitioned(the control channel’spartition/snr ... offcut this link), ormalformed(bad magic/ version/length — Mode B’s own addition tosim_drop’s reason vocabulary: Mode A never sees corrupt wire bytes, only this real UDP seam can).
./build/lrcbridge --listen 127.0.0.1:9500 --config topology.scn --trace out.jsonltests/smoke_modeb.py
Section titled “tests/smoke_modeb.py”Pattern-matches tests/smoke_lrcd.py (stdlib only, spawns the real
lrcd/lrcbridge binaries, hard timeouts, cleans up every process). Five
cases, all bounded well under CI’s 3-minute budget (~25 s total measured):
- RF-only federation — two routers with NO
--peerat all federate purely overlrcbridge: JOIN, channel chat, and a cross-node DM all converge. This proves the pathsmoke_lrcd.py’s TCP-only cases never touch — directory/registration/RTRSYNC ridingon_radio_frame, noton_peer_frame. SIGKILLpersistence torture — one router is hard-killed (Popen.kill()→SIGKILL, not a gracefulterminate()) mid-traffic and restarted with the SAME--state-dir: the survivor keeps serving its own local traffic uninterrupted (no TCP link-down machinery applies to an RF seam — there’s no keepalive/link-state concept, matching real radio silence), the restarted router comes back with a fresh boot-id and resumes sequencing without replaying anything twice, and post-restart traffic reconverges with no duplicate delivery on either side.- Partition / heal —
lrcbridge’spartition/healcontrol verbs cut and restore the simulated link while both routers keep serving local traffic; a message sent during the partition is provably NOT delivered across it, and arrives once the link heals (RTRSYNC/relay catch-up), alongside immediate fresh two-way traffic post-heal. - Mid-run lane retune —
/msg *lrc rf retuneon both routers (single- tuner gating means retuning only one side would just silence the link, not prove a retune “works”) moves them from the anchor lane to REACH on a new freq_slot; asserts theTUNEdatagram was genuinely re-announced (lrc_rfudp_tune_tx/lrc_rfudp_retunecounters climb on both routers) and that fresh traffic still converges once both sides have retuned and settled. lrcbridge --trace— runs a bridge with tracing on against a three-node topology (one node parked 500 km out, guaranteeing abelow_floordrop), drives one delivered channel message, and asserts the trace file is valid JSONL containing at least onepacket_rxand onesim_dropevent with the documented fields.
Run: python3 tests/smoke_modeb.py [path-to-lrcd] [path-to-lrcbridge].
Known gaps
Section titled “Known gaps”- No RF propagation between
lrcbridgeand Mode A’sassertgrammar. Mode B’s trace is now the SAME schema/writer Mode A’s is (previous paragraph), but there is still no Mode Bassertvocabulary — fault injection stays imperative (partition/heal/snr), and correctness is checked the same waysmoke_lrcd.pychecks it: scripted IRC clients and metrics, not a trace-driven pass/fail (chirpscope replay is the trace’s actual consumer, not a Mode B-native assertion pass). - The bridge is O(n) per-recv over known peers, matching lrcsim’s own
documented
O(n²)-per-round tradeoff (LRCSIM.md §7) — fine at Mode B’s intended scale (a handful of processes proving persistence/process behavior, not a 500-node capacity run; that’s still Mode A’s job).
What still needs real hardware (and why)
Section titled “What still needs real hardware (and why)”Per AGENTS.md rule 5, RF safety is not reviewable by CI. The human-gated set
shrinks to what can only be answered with a real antenna:
- TX power and regulatory airtime on actual silicon.
VirtualRadioproves the logic that decides when to TX and on which lane; the bench proves the radio retunes and radiates within the regulatory power/airtime budget (docs/HARDWARE.md,docs/POWER.md). - Asymmetric-return and RF-backbone actuation on real boards. The framing, auth, and lane-selection logic is host-testable; the physical retune and on-air timing need a multi-radio bench.
- Anything touching
firmware/variants/, RF-switch handling, TCXO voltage, or TX paths. Receive-only defaults; human sign-off recorded in the PR.
Everything else — the wire codec, crypto/identity, registration, transport recovery, lanes/airtime policy, the IRC gateway, the TUI, admin/oper, federation netsplit/catchup, DCC — is host-testable and runs in CI.