Leviculum
Leviculum is a Rust implementation of the Reticulum network stack. It is wire-compatible with the Python reference implementation and runs on Linux, macOS, and embedded devices.
What is Reticulum?
Reticulum is a networking stack for building resilient, encrypted mesh networks over any transport medium. It works over LoRa radios, TCP, UDP, serial links, or anything that can carry bytes. Every node gets a cryptographic identity. Every connection is end-to-end encrypted. No servers, no accounts, no infrastructure required.
What does leviculum do?
Leviculum provides the same functionality as Python Reticulum but compiled to native code. The lnsd daemon is a drop-in replacement for rnsd. The lncp file transfer tool replaces rncp. Python CLI tools like rnstatus, rnpath, and rnprobe work against a running lnsd without modification.
The protocol core (reticulum-core) compiles as no_std with only alloc, so it runs on microcontrollers. The same code powers the Linux daemon, a future Android app, and embedded firmware.
Who this manual is for
- Daemon users running
lnsdalongside or instead ofrnsd: start with the lnsd quickstart and the man pages. - Developers embedding or extending the stack: read the Concepts part — the Architecture overview plus Interface Isolation, Python-RNS Compatibility, Identity and Forward Secrecy, and Storage and Embedding.
- Firmware flashers putting Leviculum on nRF52 boards: see the firmware section and the RNode protocol reference.
The Concepts part explains the non-obvious design ideas; the appendix carries the authoritative Reticulum and LXMF specifications.
Tools
Leviculum ships three binaries:
- lnsd – the Reticulum network daemon
- lns – multi-tool for network status, path lookup, probing, identity management, file transfer, and interactive sessions
- lncp – standalone file transfer utility (compatible with Python
rncp)
Architecture Overview
This is the entry point to the Concepts part of the manual. It covers the sans-IO core, the crate split, the driver event loop, and the platform-abstraction traits — the mechanics that the four concept pages build on:
- Interface Isolation — why only the interface knows its medium’s quirks.
- Python-RNS Compatibility — wire/semantic compatibility and the drop-in daemon, vs. internal parity (not a goal).
- Identity and Forward Secrecy — dual keypairs, derived destinations, ratchets.
- Storage and Embedding — the
Clock/Storage/Interfacetraits that let one core run on a host or a microcontroller.
The crate split
The protocol logic lives in one no_std crate; everything platform-
specific wraps around it:
| Crate | Role |
|---|---|
reticulum-core | All protocol logic, #![no_std] + alloc, zero async (reticulum-core/src/lib.rs:59). |
reticulum-std | Host driver: tokio event loop, interfaces, FileStorage, RPC, config. |
reticulum-nrf | Embedded driver: Embassy event loop on nRF52 (cross-compiled, outside the host workspace). |
reticulum-ffi | C ABI over the core for other-language bindings. |
reticulum-cli | The lnsd / lns / lncp binaries. |
The application boundary is NodeCore: feed it bytes via
handle_packet / handle_timeout and drain a
TickOutput { actions, events }. The core decides what to send; the
driver decides how and when to put it on the wire. See
Storage and Embedding for the
injected Clock/Storage/Interface traits that make this portable.
Sans-I/O Core
┌─────────────────────────────────┐
│ reticulum-core │
│ │
handle_packet() ──►│ NodeCore<R, C, S> │──► TickOutput {
(iface_id, data) │ ├── Transport (routing) │ actions: Vec<Action>,
│ ├── Links + Channels │ events: Vec<NodeEvent>,
handle_timeout() ─►│ └── Destinations │ }
│ │
next_deadline() ──►│ Returns: Option<u64> │
└─────────────────────────────────┘
Action::SendPacket { iface, data } — send to one interface
Action::Broadcast { data, exclude } — send to all interfaces (except one)
Driver Event Loop
The reticulum-std driver has 6 select! branches:
#![allow(unused)]
fn main() {
loop {
select! {
// 1. Packet from any interface
(iface_id, data) = registry.recv_any() => {
output = core.handle_packet(iface_id, &data);
post_dispatch(output);
}
// 2. External action (connect, send, announce)
output = action_dispatch_rx.recv() => { post_dispatch(output); }
// 3. Timer fires
_ = sleep_until(next_poll) => {
output = core.handle_timeout();
post_dispatch(output);
}
// 4. Shutdown
_ = shutdown.changed() => break
// 5. New interface (TCP accept, local client connect)
handle = new_interface_rx.recv() => {
registry.register(handle);
output = core.handle_interface_up(iface_idx);
post_dispatch(output);
}
// 6. Periodic storage flush (crash protection, hourly)
_ = sleep_until(next_flush) => { core.storage_mut().flush(); }
}
}
}
Post-dispatch (after every core call)
dispatch_actions(&mut ifaces, &output.actions)— routes Actions to interfaces (protocol logic in core)- React to errors —
BufferFull: log.Disconnected: callhandle_interface_down() - Forward
output.eventsto the application - Schedule
handle_timeout()fromoutput.next_deadline_ms
Interface Trait
#![allow(unused)]
fn main() {
pub trait Interface {
fn id(&self) -> InterfaceId;
fn name(&self) -> &str;
fn mtu(&self) -> usize;
fn is_online(&self) -> bool;
fn try_send(&mut self, data: &[u8]) -> Result<(), InterfaceError>;
}
}
Send-only. Receive is driver-specific (tokio: mpsc::poll_recv, Embassy:
interrupt DMA, bare-metal: poll FIFO). try_send is fire-and-forget:
Reticulum is best-effort, higher layers retransmit.
dispatch_actions() lives in core (not the driver) because action routing
(broadcast exclusion, interface selection) is protocol knowledge.
In reticulum-std, InterfaceHandle wraps tokio::sync::mpsc::Sender
behind the trait. An embedded driver implements it directly on a radio struct.
Core processes packets with zero delay. Collision avoidance (jitter, CSMA) is the interface’s responsibility — fast interfaces (TCP) transmit immediately, slow interfaces (LoRa) apply send-side jitter. This is the interface-isolation rule in code.
Writing a Driver
1. Create interface objects
Implement Interface on your outbound channel. Register with your own
bookkeeping. Core references interfaces by InterfaceId only.
2. Run the event loop
Minimum 3 branches: receive, timer, shutdown. Feed everything through the post-dispatch sequence above.
3. Handle the receive path
Driver-specific. On complete packet: core.handle_packet(iface_id, &data)
→ post-dispatch. On disconnect: core.handle_interface_down(iface_id).
Packet Flow
Incoming
Interface → deframe → mpsc → recv_any() → handle_packet()
→ Transport::process_incoming() → TickOutput
→ dispatch_actions() → interfaces → wire
→ events → application
Outgoing
Application → connect/send/announce → TickOutput (via action_dispatch)
→ dispatch_actions() → interfaces → wire
Local Client (Shared Instance)
lns/lncp → Unix socket → LocalInterface (HDLC)
→ handle_packet() with is_local_client=true
→ local_client_known_dests updated (6h TTL)
RPC (rnstatus, rnpath, rnprobe)
Python CLI → Unix socket → RPC server (multiprocessing.connection, pickle)
→ handlers query NodeCore state or trigger probe
→ pickle response → CLI
The shared-instance socket and this RPC channel are what make lnsd a
drop-in for rnsd; see
Python-RNS Compatibility.
IPC platform support
The shared-instance data channel and the RPC control channel use abstract Unix sockets on Linux, filesystem Unix sockets on macOS/BSD, and TCP loopback on Windows (mirroring Python-RNS’s AF_INET fallback). Linux is the tested path and is the one exercised by our CI; macOS/Windows IPC is community-supported and not exercised by our CI.
Storage Trait
For the conceptual rationale (one core, host or embedded backend) see Storage and Embedding; for the per-method deep dive see Storage Trait Split Analysis.
Type-safe methods organized by collection:
| Collection | Key methods |
|---|---|
| Packet dedup | has_packet_hash, add_packet_hash |
| Path table | get_path, set_path, remove_path, expire_paths |
| Reverse table | get_reverse, set_reverse, remove_reverse |
| Link table | get_link_entry, set_link_entry, remove_link_entry |
| Announce table | get_announce, set_announce, remove_announce |
| Announce cache | get_announce_cache, set_announce_cache |
| Receipts | get_receipt, set_receipt, remove_receipt |
| Ratchets | load_ratchet, store_ratchet, list_ratchet_keys |
| Cleanup | expire_* per collection |
Shared types in storage_types.rs: PathEntry, ReverseEntry, LinkEntry,
AnnounceEntry, PacketReceipt.
Implementations: NoStorage (no-op), MemoryStorage (BTreeMap, host/tests),
EmbeddedStorage (heapless FnvIndexMap, fixed capacity, used by reticulum-nrf),
FileStorage (wraps MemoryStorage + disk).
FileStorage Persistence
| File | Format | Strategy | Contents |
|---|---|---|---|
known_destinations | msgpack map | Batch flush (hourly + shutdown) | Identity → destination |
packet_hashlist | msgpack array | Batch flush | 32-byte dedup hashes |
ratchets/{hash} | msgpack map | Write-through | Receiver ratchet keys |
ratchetkeys/{hash} | signed msgpack | Write-through | Sender ratchet private keys |
Non-persistent collections (paths, reverses, links, announces, receipts) are RAM-only and rebuilt from network on restart.
Logging
Sentence-style messages with inline context. Good:
Destination <81b22f60> is now 4 hops away via <ecc35451> on iface 1
Answering path request for <4c0c6c7f> on iface 1, path is known
Bad:
path updated dest=81b22f60 hops=4
Use HexShort for hashes. Always explain drop reasons (“rate limited”,
“duplicate packet”, “no path known”).
| Component | What | Level |
|---|---|---|
| transport process_incoming | Packet dispatch, drop reasons | trace! |
| transport handle_announce | Path updates, rebroadcast decisions | debug! |
| transport forward_packet | Forwarding decisions | debug! |
| node/link_management | Link lifecycle, RTT retry | debug! |
| driver | Startup, interface registration | info! |
| interfaces | Connection events, I/O errors | info!/warn! |
Interface Isolation
The single most important architectural rule in Leviculum:
Only the interface knows the quirks of its carrier medium. The core, the transport, and the daemon are media-agnostic.
A packet is a packet. At the boundary where the core hands bytes to an interface, there is no distinction between an announce, a link request, a data packet, or a resource chunk. They are all just bytes.
What “media-agnostic core” means
reticulum-core decides what to send and to which interface. It
never decides when to put a frame on the wire, never spaces
transmissions, and never reasons about contention. The core processes
every packet with zero delay and emits an Action::SendPacket or
Action::Broadcast immediately (see Architecture).
Because the core is the same code on a Linux daemon, an Android app,
and an nRF52 firmware image, it cannot afford to know whether the
medium underneath is a fibre-fast TCP socket or a half-duplex LoRa
radio whose airtime budget is measured in minutes. Medium awareness
lives entirely on the far side of the
Interface trait.
What an interface is allowed to know
A LoRa interface knows it cannot transmit and receive at the same
time. It knows its RadioSettings (bandwidth, spreading factor,
coding rate) and therefore the airtime cost of any given frame. It
holds packets back, applies its own randomised pre-TX jitter on top of
the RNode firmware’s CSMA, and refuses new frames when its airtime
budget is exhausted. Concretely:
- Send-side jitter — packets are queued, not sent immediately; the
jitter window is sized from the radio parameters so two nodes do not
re-collide (
reticulum-std/src/interfaces/rnode.rs:130, thecompute_jitter_max_msdoc comment, and the jitter queue at:448). - CSMA — radio-level carrier sensing is handled by the RNode
firmware; the interface defers collision avoidance to it rather than
the core (
reticulum-std/src/interfaces/rnode.rs:451). - Airtime backpressure — a per-interface credit bucket charges
every send by its airtime cost and signals
BufferFullrather than flooding the serial queue (reticulum-std/src/interfaces/airtime.rs:1). This explicitly “never leaks intoreticulum-core, so theno_stdcore stays free of host-side backpressure concerns” (same file).
A TCP interface has none of this. It just writes bytes
(reticulum-std/src/interfaces/tcp.rs).
Why the rule is hard, not advisory
The rule binds anyone writing a fix. If a proposed fix for a
collision, contention, or duplex problem introduces an awareness flag
or counter in transport.rs, the node/ modules, or the daemon
(“is a link in flight?”, “am I forwarding a link request?”), it is at
the wrong layer. Such a fix must be redirected into the interface.
Interface implementations are therefore free to diverge from Python-Reticulum’s thin serial-writer style — that divergence is exactly where medium-specific intelligence belongs, and it satisfies the project’s deviation rule as long as wire and semantic compatibility are preserved.
Consequences
- The same routing logic runs unchanged over LoRa, TCP, UDP, serial, and the in-process local socket.
- New media are added by implementing one trait, not by threading medium-specific cases through the protocol core.
- Collision-avoidance bugs are debugged in one place — the interface — instead of being smeared across six stack layers.
See also: Storage and Embedding for the parallel isolation of persistence and time, and the RNode protocol page for the LoRa carrier details an interface must handle.
Python-RNS Compatibility
Leviculum is built to live in the same mesh as Python Reticulum
(rnsd) and to be a drop-in replacement for the daemon and its
tooling. Compatibility is pursued at two distinct levels, and one
thing that is not pursued at all.
Level 1: wire and semantic compatibility
The protocol the two stacks speak must be identical on the air. The exact bytes of identities, destinations, announces, packets, and links are fixed by the Reticulum specification; the message format layered on top is fixed by the LXMF specification. Leviculum implements those formats so that a Python peer cannot tell a Leviculum neighbour from another Python node.
Semantic compatibility goes beyond byte layout: behaviours a Python peer expects from a neighbour — answering path requests, rebroadcast decisions, link lifecycle, ratchet handling — must still be delivered. Where the precise expected behaviour matters and is subtle, it is captured as a source-of-truth reference; the broadcast path is documented in Broadcast: Python-RNS parity reference, which records what Python does for every broadcast mechanism so the Rust core can match it.
Level 2: drop-in daemon and tooling
lnsd shares two interfaces with Python’s rnsd:
- The shared-instance IPC socket. A running daemon exposes a local
control/data channel that client tools connect to. Leviculum speaks
the same protocol, so Python’s
rnstatus,rnpath,rnprobe, andrncpdrive a runninglnsdwithout modification, and the Leviculum toolslnsandlncpdrive a runningrnsdjust the same. The RPC control channel that backsrnstatus/rnpath/rnprobeis implemented inreticulum-std/src/rpc/(it speaks Python’smultiprocessing.connectionframing with pickle payloads, seerpc/connection.rsandrpc/pickle.rs). - The config-file format.
lnsdparses the same INI-style config thatrnsduses (reticulum-std/src/config.rs,reticulum-std/src/ini_config.rs). Even keys Leviculum does not act on are parsed for compatibility — for exampleshared_instance_typeandshared_instance_socketare read and honoured per RNS 1.3.x semantics so an existingrnsdconfig works unchanged (reticulum-std/src/config.rs:47-53).
This drop-in property is a deliberate design goal, not an accident.
It is also what makes honest A/B testing possible: the test harness
points the same client binary (e.g. lns selftest) at either
daemon, never a parallel per-stack driver. A parallel driver would
smuggle configuration differences into what claims to be a stack
comparison.
What is explicitly not a goal: internal parity
Compatibility is not the same as parity.
- Compatibility — our stacks interoperate at the wire and semantic level.
- Parity — our internals mirror Python’s (same algorithms, same retry timings, same state-machine structure).
Leviculum needs the first, not the second. The historical parity
documents under docs/src/architecture-*-python-parity.md are
reference material for getting behaviour right, not commitments to
maintain identical internals.
The deviation rule
A deviation from Python-RNS’s implementation is acceptable if and only if all three of the following hold:
- Wire-format compatibility is preserved.
- Semantic compatibility is preserved (behaviours Python peers expect from a neighbour are still delivered).
- The deviation measurably improves robustness or mesh delivery.
“Because Python does it differently” is not, on its own, an objection; only “this breaks wire or semantic compatibility” is. The interface-isolation design — interfaces applying their own jitter, CSMA, and airtime budgeting — is a deliberate deviation that satisfies this rule.
Cryptographic Identity and Forward Secrecy
Every node and every endpoint in a Reticulum network is identified by cryptography, not by an address handed out by infrastructure. This page explains the conceptual model. For the exact byte layouts, defer to the Reticulum specification (its Identity, Destination, and Announce sections).
Identities are dual keypairs
A Reticulum identity holds two keypairs, used for two different
jobs (reticulum-core/src/identity.rs:45):
- X25519 — for key agreement (ECDH). This is how two parties derive a shared secret to encrypt traffic to each other.
- Ed25519 — for digital signatures. This is how a node proves an announce or a packet genuinely came from the holder of the identity.
An identity may be full (it holds the private halves and can decrypt
and sign) or public-only (it holds just the public keys, learned
from someone else’s announce, and can only encrypt and verify). In the
source this is the difference between the Option-wrapped private
fields and the always-present public fields
(reticulum-core/src/identity.rs:48).
Destinations are derived addresses
You do not pick a Reticulum address; you derive one. A
Destination is an addressable
endpoint whose 16-byte hash is computed from an application name, a
set of aspects, and (for most types) an identity
(reticulum-core/src/destination.rs:1). Because the address is a hash
of stable inputs, it is reproducible and self-authenticating: anyone
who knows the inputs computes the same address, and the identity bound
into it proves ownership.
A destination also carries a type (SINGLE, GROUP, PLAIN,
LINK) that selects its encryption behaviour, and a direction
(IN, OUT) that selects whether it can receive or send
(reticulum-core/src/destination.rs:6-7).
Announces carry the public keys
A node makes itself reachable by broadcasting an announce: a signed notification that carries the destination’s public keys out into the mesh. Peers that receive it learn the destination’s address and the keys needed to encrypt to it, and Transport learns a path back. The exact announce wire format is specified in the Reticulum spec.
Ratchets: forward secrecy without a link
End-to-end encryption protects traffic in flight, but if a long-lived
identity key is ever compromised, an attacker who recorded past
ciphertext could decrypt it. Ratchets close that window for
packets sent to SINGLE destinations without first establishing a
Link (reticulum-core/src/ratchet.rs:1).
The mechanism, conceptually:
- A destination enables ratchets and generates an initial X25519 keypair.
- It includes the current ratchet public key in its announces.
- Senders encrypt to the ratchet public key, not the long-term identity key.
- The destination rotates its ratchet keypair periodically (default ~30 minutes).
- Old ratchets are retained for a while so late-arriving packets still decrypt (default 512 retained), then discarded.
Because the rotating key is short-lived and the private half is thrown away after rotation, compromising the long-term identity does not expose traffic encrypted to expired ratchets. That is forward secrecy.
Persisting ratchet keys across restarts is the job of the
Storage trait (the ratchets/ and
ratchetkeys/ collections, see
Architecture). Links —
the other path to forward secrecy, via an ephemeral session handshake —
are a separate mechanism; see the
Reticulum specification.
Where to read the exact bytes
This page stays conceptual on purpose. The authoritative definitions
of identity serialisation, destination hashing, announce structure,
and ratchet encoding are in the
Reticulum specification. The
Rust types above (identity.rs, destination.rs, ratchet.rs)
implement that specification.
Storage and Embedding
reticulum-core is #![no_std] with only alloc
(reticulum-core/src/lib.rs:59). It contains no I/O, no clock, no
filesystem, and no async runtime. That is what lets the exact same
protocol code run on a Linux daemon, a future Android app, and a
bare-metal nRF52 firmware image. The bridge to the outside world is a
small set of traits the core depends on but does not implement.
Three injected dependencies
The core declares its platform needs as traits in
reticulum-core/src/traits.rs and takes implementations from the
driver:
Clock(traits.rs:162) — suppliesnow_ms(). The core never calls a system clock; time is handed in. On the host this is wall time; on the nRF52 it is the Embassy timer (reticulum-nrf/src/clock.rs:8).Storage(traits.rs:196) — supplies persistence and lookup for every collection the protocol maintains.flush()defaults to a no-op (traits.rs:466) so a RAM-only backend needs to implement nothing extra.Interface(traits.rs:97) — supplies framing and the wire (see Interface Isolation and the Interface trait).
Randomness is injected the same way, as an explicit
rng: &mut impl CryptoRngCore parameter rather than a global
(reticulum-core/src/lib.rs, “Platform Dependencies”).
The Storage trait
Rather than a generic key/value blob store, Storage exposes
type-safe methods grouped by collection — packet-dedup hashes, the
path table, the reverse table, link/announce tables, receipts, and
ratchets — with typed entries from storage_types.rs. The full method
inventory is tabulated in
Architecture.
This shape was a deliberate decision. The deep analysis of every method — who calls it, how often, and whether it matters on an embedded target — is in Storage Trait Split Analysis. Read that page before changing the trait surface.
Three backends, one core
The same NodeCore is parameterised over its Storage
implementation, so embedding is a matter of choosing a backend
(reticulum-core/src/node/mod.rs:143,
NodeCore<R: CryptoRngCore, C: Clock, S: Storage>):
| Backend | Where | Behaviour |
|---|---|---|
NoStorage | tiny / stateless | no-op |
MemoryStorage | host / tests | BTreeMap, RAM only (inner store of FileStorage) |
EmbeddedStorage | embedded (nRF52) | heapless::FnvIndexMap, fixed capacity, no allocator for maps |
FileStorage | host (reticulum-std) | wraps MemoryStorage + disk |
FileStorage persists only what must survive a restart — known
destinations, the packet dedup hashlist, and ratchet keys — and keeps
the rest (paths, reverses, links, announces, receipts) in RAM,
rebuilt from the network on restart. The file formats and flush
strategy are in
Architecture.
What the split buys you
- Host vs. embedded from one source tree.
reticulum-stdbuilds a tokio driver around the core;reticulum-nrfbuilds an Embassy driver around the same core (reticulum-nrf/src/bin/t114.rs,reticulum-nrf/src/bin/rak4631.rs, both#![no_std]and both constructing the core viaNodeCoreBuilder). - Testability. Because time and storage are injected, the core is
driven deterministically in tests — feed bytes and a fixed clock,
drain the
TickOutput, assert. This is the basis of the minimal-reproducer tests underreticulum-std/tests/mvr/. - No host concerns in the core. Backpressure, airtime budgeting,
and serial queueing live host-side in
reticulum-stdand never leak into theno_stdcore (reticulum-std/src/interfaces/airtime.rs:1).
See Architecture for the sans-IO core diagram and the driver event loop that pumps these traits.
Installation
Requirements
- Rust stable toolchain
- Git
Optional, depending on what you want to test:
- Python 3 (for interop tests)
- Docker (for integration tests)
- 2-4 RNode modems via USB (for LoRa integration tests)
No system C libraries are required. All cryptography is compiled from Rust source.
Debian/Ubuntu setup
# Rust toolchain
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env
# Interop tests
sudo apt install python3
# Integration tests
sudo apt install docker.io
sudo usermod -aG docker $USER
# LoRa tests and embedded firmware (USB serial access)
sudo usermod -aG dialout $USER
Build from source
git clone https://codeberg.org/Lew_Palm/leviculum.git
cd leviculum
cargo build --release --bin lnsd --bin lns --bin lncp
The binaries are in target/release/.
Running the daemon
./target/release/lnsd -v
Reads its config from ~/.reticulum/config, the same location as Python Reticulum.
Development
Cargo aliases
Common workflows are available as cargo aliases (defined in .cargo/config.toml):
| Command | What it does |
|---|---|
cargo test-core | Run all reticulum-core unit tests |
cargo test-std | Run all reticulum-std unit tests |
cargo test-interop | Run interop tests against Python Reticulum |
cargo test-integ | Run Docker-based integration tests |
cargo lint | Run clippy on all crates |
cargo fmt --all -- --check | Check formatting |
Test levels
Tests are organized by what they require:
Unit tests – just Rust, no extra dependencies:
cargo test-core
cargo test-std
Interop tests – require Python 3 and the vendored Reticulum:
git submodule update --init vendor/Reticulum
cargo test-interop
Integration tests – require Docker and pre-built release binaries:
cargo build --release --bin lnsd --bin lns --bin lncp
cargo test-integ
LoRa integration tests – require physical RNode modems connected via USB:
LoRa tests are #[ignore]d by default and must be run explicitly. They
exercise real over-the-air transfers between RNode radios running Reticulum
firmware. Tests are skipped automatically if the required devices are not
connected.
| Devices needed | Test count | Examples |
|---|---|---|
| 2 RNodes | 40 | lora_link_rust, lora_lncp_push, lora_ratchet_basic |
| 3 RNodes | 3 | lora_3node_transfer, lora_3node_contention |
| 4 RNodes | 7 | lora_4node_contention_rust, lora_multihop_transfer |
Hardware setup:
- Connect RNodes via USB. They appear as
/dev/ttyACM0,/dev/ttyACM1, etc. - Your user must be in the
dialoutgroup:sudo usermod -aG dialout $USER - Override device paths with environment variables if needed:
LEVICULUM_RNODE_0=/dev/ttyUSB0 LEVICULUM_RNODE_1=/dev/ttyUSB1
Running LoRa tests:
# Single test
cargo test -p reticulum-integ -- --exact executor::tests::lora_link_rust --ignored --nocapture
# All 2-device tests
cargo test -p reticulum-integ -- lora_ --ignored --nocapture --test-threads=1
# Override radio parameters (bandwidth in Hz)
LORA_BANDWIDTH=125000 cargo test -p reticulum-integ -- --exact executor::tests::lora_lncp_push --ignored --nocapture
Each LoRa test must pass on all three bandwidth profiles (62.5 kHz, 125 kHz,
250 kHz). The TOML files define 62.5 kHz; use LORA_BANDWIDTH to switch.
Some tests use the lora-proxy binary for fault injection (dropping frames
to test retransmit recovery). Build it before running proxy tests:
cargo build --release --bin lora-proxy
Embedded cross-compilation
Embedded targets are not downloaded automatically. Install them when needed:
rustup target add thumbv7em-none-eabihf # nRF52840
rustup target add thumbv6m-none-eabi # RP2040
cargo check-nrf52
cargo check-embedded
Before submitting changes
cargo fmt --all -- --check
cargo lint
cargo test-core
cargo test-interop
Configuration
lnsd reads the same INI-style configuration file as Python Reticulum
(rnsd). The format is a drop-in: a config that rnsd accepts, lnsd
accepts, and the two share the shared-instance IPC socket so client
tools (rnstatus, rncp, lns diag, Sideband, Nomadnet) attach to
either daemon without changes. Keys lnsd does not implement are
tolerated, not rejected — an unknown key never makes lnsd refuse a
config a current rnsd would load (ini_config.rs:193-198).
File location and lookup order
Pass an explicit config directory with --config DIR (lnsd.rs,
-c/--config). With no flag, lnsd resolves the directory using the
same order as Python Reticulum (config.rs:360-391):
/etc/reticulum— if/etc/reticulum/configexists$HOME/.config/reticulum— if that directory’sconfigexists$HOME/.reticulum— fallback, used even if absent
The config file is always named config inside that directory
(config.rs:369-371). The storage directory defaults to
<config_dir>/storage and can be overridden with --storage
(lnsd.rs, -s/--storage).
This order is why the Debian package can install a system-wide config
under /etc/reticulum and have Python clients connect to the live
daemon with no extra flags (config.rs:354-359).
INI vs TOML detection
lnsd accepts both the Python INI format and native TOML. Detection is
by content, not just extension (config.rs:315-338):
- An explicit
.tomlextension forces TOML. - A file containing
[[(the ConfigObj subsection marker Python uses for interfaces) is parsed as INI. - Otherwise TOML is tried first, then INI as a fallback.
In practice your config file uses the Python INI form shown
throughout this page. Boolean values accept Yes, yes, True,
true, 1, on (and their false counterparts); anything else is read
as false (ini_config.rs:255-257).
The [reticulum] section
Core daemon settings. Every key below is parsed in
ini_config.rs:153-199; defaults come from config.rs:137-155.
| Key | Type | Default | Meaning |
|---|---|---|---|
enable_transport | bool | true | Route announces and serve paths for other peers. lnsd defaults this to true (it is a daemon); the Python library default is false. (config.rs:27-28, 140) |
use_implicit_proof | bool | true | Use implicit proof for link identification. (config.rs:30-31, 141) |
share_instance | bool | false | Listen on the abstract Unix socket \0rns/<instance_name> for local clients. Required for lns diag, rnstatus, Sideband etc. to attach. (config.rs:32-35, 142; key share_instance → shared_instance, ini_config.rs:158-159) |
instance_name | string | default | Names the shared-instance socket: \0rns/<instance_name>. Use a unique name to run two daemons side by side. (config.rs:36-39, 143; ini_config.rs:161-163) |
shared_instance_type | unix/tcp | unset | Parsed for rnsd compatibility. Only tcp/unix are stored; tcp clears shared_instance_socket (tcp disables AF_UNIX upstream). lnsd currently serves only the abstract AF_UNIX socket. (config.rs:40-47; ini_config.rs:164-173, 126-128) |
shared_instance_socket | path | unset | Explicit AF_UNIX socket path (RNS 1.3.x). Parsed for compatibility; cleared when shared_instance_type = tcp. (config.rs:48-53; ini_config.rs:174-176) |
respond_to_probes | bool | false | Answer rnprobe requests by signing a proof for each probe packet. (config.rs:54-60, 146; ini_config.rs:177-179) |
remote_management_enabled | bool | false | Enable remote management. (config.rs:61-63, 147; ini_config.rs:180-182) |
storage_path | path | unset | Storage path, relative to the config dir or absolute. (config.rs:64-66, 148) |
flush_interval | u64 (sec) | 3600 | Seconds between periodic storage flushes. Crash protection only — normal shutdown always flushes. (config.rs:67-73, 149; ini_config.rs:183-187) |
control_channel_capacity | usize | 256 | Capacity of the lossless control-plane event channel (announces, paths, link/resource lifecycle). Raise on servers under heavy announce load. (config.rs:74-82, 150) |
data_channel_capacity | usize | 128 | Capacity of the droppable data-plane event channel; full means normal backpressure (silent drop). (config.rs:83-90, 151) |
keepalive_interval | u64 (sec) | unset | Override link keepalive interval. When set, every link uses this interval and the stale-link timeout scales with it (stale after twice the keepalive). Local timing only, no wire change. Useful for slow links. (config.rs:91-98, 152; ini_config.rs:188-192) |
use_implicit_proof, storage_path, control_channel_capacity, and
data_channel_capacity are read from TOML only; they have no INI key in
apply_reticulum_key (ini_config.rs:155-199 parses just the nine keys
above) and are best set in a TOML config or left at their defaults.
storage_path is also settable from the command line via lnsd --storage.
flush_interval and keepalive_interval are Leviculum tuning
extensions — Python Reticulum ignores them. Battery-powered or SD-card
deployments may want a longer flush_interval; slow links benefit from
a fixed keepalive_interval:
[reticulum]
# Seconds between periodic storage flushes (crash protection only,
# normal shutdown always flushes). Default: 3600.
flush_interval = 3600
# Link keepalive interval in seconds. When set, every link uses this
# interval instead of the RTT-derived default. Default: unset.
keepalive_interval = 360
The [interfaces] section
Interfaces are ConfigObj subsections under [interfaces], each named in
double brackets [[Name]]. The name is free-form; the type key
selects the interface implementation. Six types are supported
(ini_config.rs:133-145):
TCPServerInterface, TCPClientInterface, UDPInterface,
AutoInterface, RNodeInterface, SerialInterface.
An interface of any other type is skipped with a log line, not an error
(ini_config.rs:136-143).
All interface keys are parsed in ini_config.rs:202-247; struct
defaults are in config.rs:259-305.
Keys common to every interface
| Key | Type | Default | Meaning |
|---|---|---|---|
type | string | (required) | Interface type, one of the six above. (ini_config.rs:204) |
enabled | bool | true | Bring this interface up. (ini_config.rs:205; config.rs:164-165) |
outgoing | bool | true | Allow sending outgoing packets. (ini_config.rs:206; config.rs:167-168) |
bitrate | u64 (bps) | 62500 | Advertised link bitrate, used for airtime accounting. (ini_config.rs:218-222; config.rs:170-171, 253) |
buffer_size | usize | per type | Channel buffer size. (ini_config.rs:223; config.rs:200-201) |
TCP server (TCPServerInterface)
| Key | Type | Default | Meaning |
|---|---|---|---|
listen_ip | string | unset | Address to bind. (ini_config.rs:207) |
listen_port | u16 | unset | Port to listen on. (ini_config.rs:208) |
[interfaces]
[[Loopback TCP]]
type = TCPServerInterface
enabled = Yes
listen_ip = 127.0.0.1
listen_port = 45999
TCP client (TCPClientInterface)
| Key | Type | Default | Meaning |
|---|---|---|---|
target_host | string | unset | Remote host to connect to. (ini_config.rs:209) |
target_port | u16 | unset | Remote port. (ini_config.rs:210) |
reconnect_interval | u64 (sec) | 5 | Delay between reconnect attempts. (ini_config.rs:224; config.rs:202-203) |
max_reconnect_tries | u64 | unlimited | Give up after this many attempts; unset means never. (ini_config.rs:225; config.rs:204-205) |
[interfaces]
[[RNS TCP Node Germany 002]]
type = TCPClientInterface
enabled = Yes
target_host = 193.26.158.230
target_port = 4965
UDP (UDPInterface)
| Key | Type | Default | Meaning |
|---|---|---|---|
listen_ip | string | unset | Local bind address. (ini_config.rs:207) |
listen_port | u16 | unset | Local bind port. (ini_config.rs:208) |
forward_ip | string | unset | Broadcast/forward address. (ini_config.rs:211) |
forward_port | u16 | unset | Broadcast/forward port. (ini_config.rs:212) |
AutoInterface (AutoInterface)
Discovers other Reticulum nodes on the same broadcast domain via multicast. No router or DHCP needed; the link must carry multicast.
| Key | Type | Default | Meaning |
|---|---|---|---|
group_id | string | unset | Multicast group identifier; isolate co-located meshes by setting different IDs. (ini_config.rs:232; config.rs:208-209) |
discovery_scope | string | unset | Multicast scope: link, admin, site, organisation, global. (ini_config.rs:233; config.rs:210-211) |
discovery_port | u16 | 29716 | Discovery (announce) port. (ini_config.rs:234; config.rs:212-213) |
data_port | u16 | 42671 | Data port. (ini_config.rs:235; config.rs:214-215) |
devices | string (CSV) | unset | Whitelist of NIC names to use. (ini_config.rs:236; config.rs:216-217) |
ignored_devices | string (CSV) | unset | Blacklist of NIC names to skip. (ini_config.rs:237; config.rs:218-219) |
multicast_loopback | bool | unset | Enable multicast loopback (same-machine testing). (ini_config.rs:238; config.rs:220-221) |
RNode and Serial (RNodeInterface, SerialInterface)
RNodeInterface drives an RNode LoRa modem; SerialInterface is a raw
serial KISS link. They share the serial-port and (for RNode) LoRa keys.
Serial keys:
| Key | Type | Default | Meaning |
|---|---|---|---|
port | string | unset | Serial device path, e.g. /dev/ttyACM0. (ini_config.rs:213; config.rs:188-189) |
speed / baudrate | u32 | unset | Serial baud rate (either spelling). (ini_config.rs:214; config.rs:190-191) |
databits | u8 | unset | Data bits. (ini_config.rs:215; config.rs:192-193) |
parity | string | unset | none, even, or odd. (ini_config.rs:216; config.rs:194-195) |
stopbits | u8 | unset | Stop bits. (ini_config.rs:217; config.rs:196-197) |
LoRa keys (RNode), derived from source — the meanings below describe the
RNode radio parameters the interface configures (config.rs:231-249):
| Key | Type | Default | Meaning |
|---|---|---|---|
frequency | u64 (Hz) | unset | LoRa centre frequency. (ini_config.rs:226; config.rs:232-233) |
bandwidth | u32 (Hz) | unset | LoRa bandwidth. (ini_config.rs:227; config.rs:234-235) |
spreadingfactor / spreading_factor | u8 | unset | LoRa spreading factor (either spelling). (ini_config.rs:228; config.rs:236-237) |
codingrate / coding_rate | u8 | unset | LoRa coding rate (either spelling). (ini_config.rs:229; config.rs:238-239) |
txpower / tx_power | i8 (dBm) | unset | Transmit power (either spelling). (ini_config.rs:230; config.rs:240-241) |
flow_control | bool | unset | Wait for the RNode’s CMD_READY before the next TX. (ini_config.rs:239; config.rs:242-243) |
airtime_limit_short | f64 (%) | unset | Short-term airtime cap, percent (0.0–100.0). (ini_config.rs:240; config.rs:244-245) |
airtime_limit_long | f64 (%) | unset | Long-term airtime cap, percent (0.0–100.0). (ini_config.rs:241; config.rs:246-247) |
csma_enabled | bool | unset | Enable CSMA/CA on the T114 LoRa interface (needs CAD-capable firmware). (ini_config.rs:242; config.rs:248-249) |
IFAC (Interface Access Codes)
IFAC keys apply to any interface and authenticate / isolate a virtual
network on the link. They are common to all interface types
(ini_config.rs:243-245):
| Key | Type | Default | Meaning |
|---|---|---|---|
networkname / network_name | string | unset | Network name for IFAC (either spelling). (ini_config.rs:243; config.rs:224-225) |
passphrase | string | unset | IFAC passphrase. (ini_config.rs:244; config.rs:226-227) |
ifac_size | usize (bits) | unset | IFAC size, specified in bits in the file and stored as bytes (bits / 8). (ini_config.rs:245; config.rs:228-229) |
networkname and passphrase are secrets: lns diag redacts them
before serialising a bundle (see the lns diag section).
Example configurations
Simple AutoInterface node
A node that talks to other Reticulum peers on the same LAN, no transport routing:
[reticulum]
enable_transport = No
share_instance = Yes
[interfaces]
[[Default Interface]]
type = AutoInterface
enabled = Yes
TCP-server transport node
A routing entrypoint that accepts inbound TCP peers and bridges them with the local LAN:
[reticulum]
enable_transport = Yes
share_instance = Yes
instance_name = entrypoint
[interfaces]
[[Public TCP]]
type = TCPServerInterface
enabled = Yes
listen_ip = 0.0.0.0
listen_port = 4965
[[Local LAN]]
type = AutoInterface
enabled = Yes
LoRa RNode node
A node on a LoRa RNode modem (radio values below are an EU 868 MHz example; set them for your region and hardware):
[reticulum]
enable_transport = Yes
share_instance = Yes
[interfaces]
[[LoRa RNode]]
type = RNodeInterface
enabled = Yes
port = /dev/ttyACM0
frequency = 867200000
bandwidth = 125000
spreadingfactor = 8
codingrate = 5
txpower = 14
See the upstream Reticulum Manual for the protocol-level meaning of the radio and IFAC parameters.
lnsd Quickstart for Beta Testers
This page gets you from “I have the .deb file” to “my node is on the
mesh and I know how to tell if it isn’t”, plus the one-liner you run when
something is off so the bug report has everything we need.
For the protocol itself, the upstream Reticulum Manual
is the reference. This page is about getting lnsd running on your
machine.
Prerequisites
- Linux, x86_64 or aarch64. (macOS and embedded targets exist but are
out of scope for the beta
.debpath.) - The nightly
.debfor your architecture. Download links are on the releases page. The binaries inside are statically linked against musl, so the package installs on Debian ≥ 9 and Ubuntu ≥ 16.04 regardless of host glibc. - A few free TCP/UDP ports on your machine for the configured interfaces (default ports below).
You do not need to install Rust, Python, or Docker for the beta flow.
Install
sudo apt install ./leviculum-nightly-amd64.deb # or -arm64
The package:
- Installs
lnsd,lns, andlncpunder/usr/bin/. - Creates a system user
leviculumand a group of the same name. - Drops a default config at
/etc/reticulum/config(mode 2775, group-writable + setgid, so everything created inside it inherits the group). - Enables and starts the
lnsd.servicesystemd unit.
For the native tools (lns, lncp) and Python tools (rnstatus,
rnpath, rnprobe, Sideband, Nomadnet, …) to talk to the running
daemon, your user has to be in the leviculum group:
sudo usermod -aG leviculum "$USER"
# log out and back in, or `newgrp leviculum` for this shell only
Verify the installation:
lnsd --version # e.g. 0.7.0-nightly.20260419-5a5df20
lns --version
systemctl is-active lnsd
is-active should print active. If it prints failed, jump to
Troubleshooting.
Minimum-viable config
The default /etc/reticulum/config is conservative: it brings up a
single AutoInterface for local LAN peers, with transport routing
disabled. That’s enough to talk to other Reticulum nodes on the same
LAN, but it does not connect you to the wider mesh.
A reasonable beta-tester config has two interfaces: one for the LAN, one
TCP uplink to a public entrypoint. Edit /etc/reticulum/config to:
[reticulum]
# Pass announces and serve paths for other peers. Leave off if your
# machine is mobile or sleeps a lot.
enable_transport = Yes
# Required for `lns diag`, `rnstatus`, Sideband etc. to attach to
# this daemon. The default config already sets this.
share_instance = Yes
[interfaces]
# 1. Local mesh: discovers and talks to every other Reticulum node
# on the same broadcast domain. No router/DHCP needed. Multicast
# has to reach the link (most home LANs do; corporate Wi-Fi often
# does not).
[[Default Interface]]
type = AutoInterface
enabled = Yes
# 2. TCP uplink to a public entrypoint. Pick a node from the
# community directory: https://directory.rns.recipes/ (entrypoints
# rotate; for redundancy add two or three, and see the Reticulum
# manual's "Bootstrapping Connectivity" section for the
# discover_interfaces auto-peering option). Example below: the
# RNS TCP Node Germany 002 entry.
[[RNS TCP Node Germany 002]]
type = TCPClientInterface
enabled = Yes
target_host = 193.26.158.230
target_port = 4965
Then restart the daemon so it picks up the new config:
sudo systemctl restart lnsd
lns diag (below) is the easiest way to confirm both interfaces came
up.
Start the daemon
The systemd unit handles this for you on install. The relevant commands:
sudo systemctl start lnsd # or restart
sudo systemctl stop lnsd
sudo systemctl status lnsd
journalctl -u lnsd -f # live log tail
journalctl -u lnsd --since '10 min ago'
Logs go to the journal. Increase verbosity by editing the unit’s
ExecStart to add -v (debug) or -vv (trace), then
sudo systemctl daemon-reload && sudo systemctl restart lnsd. The
RUST_LOG environment variable also works (see lnsd(1)).
To run lnsd by hand without systemd (useful for ad-hoc debugging):
sudo systemctl stop lnsd
sudo -u leviculum /usr/bin/lnsd -v --config /etc/reticulum
Check it’s working
Three commands. Run them as a user that is in the leviculum group.
1. lns diag
This is the main health-check. It connects to the running daemon over the shared-instance socket and renders a single-file diagnostic bundle:
lns diag --config /etc/reticulum
A healthy bundle looks roughly like this (your transport id, paths,
and byte counters will differ):
===== Leviculum diagnostic bundle =====
----- Versions / build -----
lns version: 0.7.0
build profile: release
target: x86_64 / linux
daemon version: not exposed by the shared-instance RPC ...
----- Config -----
config dir: /etc/reticulum
config file: /etc/reticulum/config
config file: present, parsed OK
Effective config (TOML, secrets redacted; the raw file is NOT included
because it may contain secrets):
[reticulum]
enable_transport = true
shared_instance = true
instance_name = "default"
...
----- Daemon view (shared-instance RPC) -----
instance name: default
RPC socket: \0rns/default/rpc
authkey: derived from /etc/reticulum/storage/transport_identity (not shown)
## interface_stats
transport id: 0123456789abcdef0123456789abcdef
daemon uptime: 12m 34s (754s)
interfaces (3):
- Shared Instance[rns/default] type=LocalServerInterface status=up rxb=0 txb=0 clients=1
- AutoInterface[Default Interface/eth0/aabbccdd] type=AutoInterface status=up rxb=482 txb=917 peers=2
- TCPInterface[RNS TCP Node Germany 002/193.26.158.230:4965] type=TCPClientInterface status=up rxb=14211 txb=8332
## path_table
known paths: 7
[ ... JSON array of {hash, via, hops, expires, ...} ... ]
## link_count
active links: 0
----- System -----
os: linux kernel: 6.12.73+deb13-amd64
distro: Debian GNU/Linux 13 (trixie)
lnsd pid: 12345
lnsd VmRSS: 18432 kB
lnsd open fds: 27
----- Recent events -----
No structured event-log file specified ...
===== end of diagnostic bundle =====
What to look at first:
status=upon every interface in theinterface_statssection. An interface that came up but lost its medium reportsstatus=down.- Non-zero
rxb/txbon the interfaces you expect traffic on (AutoInterfaceonce any other Reticulum node is on the same LAN,TCPInterfaceas soon as it connects). peers=…on theAutoInterfaceline: how many other Reticulum nodes are visible on the LAN.known paths: Nwith N > 0 once announces have crossed the mesh. Brand-new daemons that haven’t heard any announces yet showknown paths: 0for the first few seconds — that’s normal.transport idis your node’s identity (the public half). It is safe to share; the private half lives in/etc/reticulum/storage/transport_identityand is never included inlns diagoutput.
2. lns selftest --help
Sanity-checks that lns itself is installed and runnable:
lns selftest --help
The actual lns selftest exercise needs one or two relay nodes you
control. The full command and options live in lns(1).
3. rnstatus (optional — Python tools)
The .deb does not install Python Reticulum. If you want rnstatus
/ rnpath / rnprobe / Sideband:
sudo apt install python3 python3-pip
pip3 install --user rns
rnstatus
Python tools auto-detect /etc/reticulum/config and connect to the
running lnsd through the same shared-instance socket. No extra flags
are needed. (lns status exists as a placeholder but is not implemented
yet — use rnstatus or lns diag until it lands.)
Connect to the wider mesh
With the config above, two things happen as soon as lnsd starts:
- Announcing. Your node sends an announce for its probe destination on every enabled interface. Other transport-enabled nodes pass that announce on, so within seconds your node is visible to peers on the LAN and within a few minutes to peers reachable through the TCP uplink.
- Learning paths. When other nodes announce, your daemon stores
a path to each announced destination (hash, next hop, hop count,
expiry).
lns diag’sknown paths: Nis that table’s size.
When you want to talk to a specific destination (e.g. send a file with
lncp), the daemon either has a path already (immediate) or requests
one (a path-request packet, a few seconds, then immediate). You don’t
have to do anything to make path discovery happen — it runs whenever
the daemon is up.
For the protocol-level picture, read Bootstrapping Connectivity in the upstream Reticulum manual.
Troubleshooting
lnsd will not start
systemctl status lnsd
journalctl -u lnsd --since '10 min ago' | tail -50
Common causes:
- Config not parsed. Look for a “Failed to parse config” line in
the journal.
lns diag --no-rpcshows the parse status without needing the daemon up:config file: present but FAILED to parse: <details> - Abstract socket already in use. Another
lnsdorrnsdis running under the sameinstance_name. Stop it (sudo systemctl stop lnsdthenpkill -f rnsdif applicable), or set a uniqueinstance_namein your config. - Permission on the storage directory. The
leviculumuser has to be able to write/etc/reticulum/storage/. The.debsets the permissions correctly on install; a manualchowntoroot:rootbreaks the daemon. Fix:sudo chown -R leviculum:leviculum /etc/reticulum sudo chmod 2775 /etc/reticulum
No peers found / known paths: 0
Check lns diag’s interface_stats section:
AutoInterfaceshowspeers: 0andrxb: 0— multicast isn’t reaching the link. Likely causes: corporate Wi-Fi (multicast blocked); a Linux bridge or container network without multicast forwarding; no other Reticulum node on the segment.TCPClientInterfaceshowsstatus=upbutrxb: 0— TCP connected but the remote isn’t sending anything, which usually means the remote is up but has no transport peers itself, or the entrypoint has been retired. Try a different entrypoint, or rely on AutoInterface + a TCP uplink to a known-good node you control.TCPClientInterfacenot listed at all — the daemon hasn’t connected yet (look forEstablishing TCP connectionlines injournalctl -u lnsd) or DNS for the target host doesn’t resolve.
Give it ~30 seconds after starting lnsd before concluding there’s a
problem — the first round of announces and the initial TCP connect
take a moment.
Native and Python tools cannot reach lnsd
Symptom: lns diag shows <unavailable: …> in the daemon-view
section, or rnstatus errors with “Reticulum is not running”.
- Confirm your user is in the
leviculumgroup:
If not,id | tr , '\n' | grep leviculumsudo usermod -aG leviculum "$USER"and log out / back in. - Confirm the daemon really is up and has
share_instance = Yes:systemctl is-active lnsd grep -i share_instance /etc/reticulum/config - Confirm both client and daemon are using the same config directory.
The client defaults to
/etc/reticulumif it exists, then~/.config/reticulum, then~/.reticulum.lns diag --config /etc/reticulumis explicit.
Submitting a bug report
Run lns diag and attach its output to your report:
lns diag --config /etc/reticulum --output /tmp/lns-diag.txt
The bundle is plain UTF-8 text, designed to be safe to attach: IFAC
passphrase and networkname are redacted before serialisation; the
node identity private key is never read into the bundle (only its
SHA-256 is used, internally, to derive the shared-instance RPC
authkey). The bundle does contain your node’s hostnames, configured
TCP targets, byte counters, and known-destinations table — review it
once before posting to a public tracker if your topology is sensitive.
If lnsd is in a structured event-log run
(LEVICULUM_EVENT_LOG=/var/log/lnsd-events.log in the service unit’s
Environment=), include the tail of that file too:
lns diag --event-log /var/log/lnsd-events.log \
--output /tmp/lns-diag.txt
Otherwise the bundle already points the reviewer at journalctl -u lnsd, which is enough.
See also
lnsd(1),lns(1),lncp(1)man pages.- Configuration for the format reference.
- Installation for the source-build path.
- The upstream Reticulum Manual for the protocol itself.
lns
lns is the Reticulum command-line utility. It manages identities,
copies files, runs an interactive session against a daemon, exercises
the stack with a self-test, and collects diagnostic bundles.
Reticulum command-line utility
Usage: lns [OPTIONS] <COMMAND>
Commands:
status Show status of the Reticulum network
path Show or request paths to destinations
identity Identity management
probe Probe a destination
interfaces Show interface information
selftest Run integration self-test through relay node(s)
cp Copy files over Reticulum (compatible with rncp)
connect Interactive session: connect to rnsd and enter command loop
diag Collect a diagnostic bundle from a running lnsd (or rnsd) for bug reports
help Print this message or the help of the given subcommand(s)
Options:
-c, --config <CONFIG> Configuration file path
-v, --verbose Enable verbose logging
--corrupt-every <CORRUPT_EVERY> Corrupt ~1 byte per N bytes on TCP write (fault injection)
-h, --help Print help
-V, --version Print version
-c/--config, -v/--verbose, and --corrupt-every are global flags
available on every subcommand. --corrupt-every is a fault-injection
tool for testing and should be left off in normal use.
identity
Manage Reticulum identities. An identity file holds the 64-byte private key (X25519 + Ed25519); the public half and the 16-byte hash are derived from it.
Usage: lns identity <COMMAND>
Commands:
generate Generate a new identity
show Show identity information
identity generate
Creates a fresh identity. With -o/--output FILE the private key is
written to the file and the hash is printed; without it, the hash and
public key are printed and nothing is saved (lns.rs:1126-1145).
lns identity generate -o my-identity.bin
Generated new identity
Hash: 0123456789abcdef0123456789abcdef
Saved to: my-identity.bin
Without -o, the public key is printed instead of being saved
(lns.rs:1140-1144):
lns identity generate
Generated new identity
Hash: 0123456789abcdef0123456789abcdef
Public key: <64 hex bytes>
identity show
Loads a saved identity file and prints its path, hash, and public key
(lns.rs:1147-1158):
lns identity show my-identity.bin
Identity: my-identity.bin
Hash: 0123456789abcdef0123456789abcdef
Public key: <64 hex bytes>
cp
Copy files over Reticulum, wire-compatible with Python’s rncp. This is
the same engine as the standalone lncp tool, exposed as an
lns subcommand.
Because -v/--verbose and -c/--config are global lns flags, cp
uses -V/--cp-verbose and --cp-config for its own verbosity and
config overrides (rncp itself uses -v and has no --config). Other
options match lncp: -l/--listen, -w TIMEOUT, -s/--save,
-O/--overwrite, -n/--no-auth, -b ANNOUNCE_INTERVAL. See the
lncp page for the full option set and worked examples.
# Send a file
lns cp report.pdf 0123456789abcdef0123456789abcdef
# Listen for incoming transfers
lns cp -l
connect
Open an interactive session against a running daemon (lnsd or rnsd)
and enter a command loop. The address is the daemon’s TCP interface
(host:port); lns verifies TCP connectivity before building the node
(lns.rs:554-560).
Usage: lns connect [OPTIONS] <ADDR>
Arguments:
<ADDR> Address of the rnsd to connect to (host:port)
Options:
-c, --config <CONFIG> Configuration file path
--identity <IDENTITY> Path to identity file (default: generate ephemeral)
With no --identity, an ephemeral identity is generated for the session
(lns.rs:564-573). On connect, the session announces itself and prints
its identity and destination hashes (lns.rs:616-619):
Identity: <hash>
Destination: <hash>
Announced as lns-cli
Type /help for commands.
>
Interactive commands
The command loop accepts these (lns.rs:534-546):
| Command | Action |
|---|---|
/peers | List discovered destinations |
/link <hash> | Initiate a link to a destination (32-char hex) |
/target <hash> | Set a single-packet destination (32-char hex) |
/untarget | Clear the single-packet target |
/send <msg> | Send data on the active link or to the target |
/close | Close the active link |
/announce | Re-announce this destination |
/quiet | Hide announce/path messages |
/verbose | Show announce/path messages |
/status | Show node status (identity, destination, paths, peers) |
/help | Show this help |
/quit | Exit |
<bare text> | Send as data on the active link or to the target |
selftest
Run an end-to-end self-test through one or two relay nodes you control.
The relay addresses are given as host:port.
Usage: lns selftest [OPTIONS] [TARGETS]...
Arguments:
[TARGETS]... Address(es) of relay node(s) (host:port). One or two addresses
Options:
-c, --config <CONFIG>
Configuration file path
--duration <DURATION>
Test duration in seconds [default: 180]
--rate <RATE>
Messages per second per direction [default: 1]
--mode <MODE>
Which test phases to run [default: all]
--discovery-timeout <DISCOVERY_TIMEOUT>
Discovery timeout in seconds (Phase 2: mutual path discovery) [default: 60]
The --mode flag selects which phases run; the values are all,
link, packet, ratchet-basic, ratchet-enforced,
bulk-transfer, and ratchet-rotation (default all). --duration
defaults to 180 seconds, --rate to 1 message per second per
direction, and --discovery-timeout to 60 seconds.
# Full self-test through one relay
lns selftest 192.0.2.10:4965
# Just the link phase, two relays, shorter run
lns selftest --mode link --duration 60 192.0.2.10:4965 192.0.2.11:4965
diag
Collect a self-contained diagnostic bundle from a running daemon for bug
reports. diag queries the shared-instance RPC for the daemon’s live
view (interface stats, path table, link count), bundles it with the
secret-redacted config, version and build info, and system info, and
prints to stdout (or to a file with --output).
Usage: lns diag [OPTIONS]
Options:
-c, --config <CONFIG>
Configuration file path
--output <OUTPUT>
Write the bundle to this path instead of stdout
--instance-name <INSTANCE_NAME>
Shared-instance name to query (default: from config, else "default")
--event-log <EVENT_LOG>
Tail this structured event-log file into the bundle
--no-rpc
Skip the daemon RPC queries; emit only config / versions / system
--no-rpc (lns.rs:491-493) skips the daemon queries — useful for
checking config parse status when the daemon is down.
A bundle is assembled from these sections in order (diag.rs:63-190):
- Versions / build —
lnsversion, build profile, target. (The daemon version is not exposed by the RPC; check the daemon’s startup log if needed.) - Config — config dir and file, parse status, then the effective config rendered as TOML with secrets redacted. The raw file is never included.
- Interfaces (configured) — each configured interface from the parsed config.
- Daemon view (shared-instance RPC) — instance name, RPC socket
path
\0rns/<name>/rpc, and liveinterface_stats,path_table, andlink_countqueries. - System — OS, kernel, distro, and the daemon’s pid / RSS / open fds.
- Recent events — tail of the structured event log if
--event-logis given.
A trimmed bundle (your transport id, paths, and counters will differ):
===== Leviculum diagnostic bundle =====
----- Versions / build -----
lns version: 0.7.0
build profile: release
target: x86_64 / linux
daemon version: not exposed by the shared-instance RPC ...
----- Config -----
config dir: /etc/reticulum
config file: /etc/reticulum/config
config file: present, parsed OK
Effective config (TOML, secrets redacted; the raw file is NOT included
because it may contain secrets):
[reticulum]
enable_transport = true
shared_instance = true
instance_name = "default"
...
----- Daemon view (shared-instance RPC) -----
instance name: default
RPC socket: \0rns/default/rpc
authkey: derived from /etc/reticulum/storage/transport_identity (not shown)
## interface_stats
transport id: 0123456789abcdef0123456789abcdef
daemon uptime: 12m 34s (754s)
interfaces (3):
- Shared Instance[rns/default] type=LocalServerInterface status=up rxb=0 txb=0 clients=1
- AutoInterface[Default Interface/eth0/aabbccdd] type=AutoInterface status=up rxb=482 txb=917 peers=2
- TCPInterface[RNS TCP Node Germany 002/193.26.158.230:4965] type=TCPClientInterface status=up rxb=14211 txb=8332
## path_table
known paths: 7
[ ... JSON array of {hash, via, hops, expires, ...} ... ]
## link_count
active links: 0
----- System -----
os: linux kernel: 6.12.73+deb13-amd64
distro: Debian GNU/Linux 13 (trixie)
lnsd pid: 12345
lnsd VmRSS: 18432 kB
lnsd open fds: 27
----- Recent events -----
No structured event-log file specified ...
===== end of diagnostic bundle =====
Secret redaction
The bundle is designed to be safe to attach to a public tracker. IFAC
passphrase and networkname are redacted before the config is
serialised, and the node’s private key is never read into the bundle —
only its hash is used internally to derive the RPC authkey
(diag.rs:119-128, 162-168). The bundle still contains your
hostnames, configured TCP targets, byte counters, and known-paths table,
so review it once before posting if your topology is sensitive.
lns diag --config /etc/reticulum --output /tmp/lns-diag.txt
See the lnsd Quickstart for how to read the bundle as a health check.
Planned commands
status, path, probe, and interfaces are present as placeholders
but are not implemented yet — they print a “Not implemented yet”
notice and exit 0 (lns.rs:1104-1174). They are tracked as Codeberg
issue #22. Until they land:
- For status, use
lns diagor the Pythonrnstatus. - For paths and interfaces, use the
interface_statsandpath_tablesections oflns diag, orrnpath/rnstatus. - For probing a destination, use the Python
rnprobe.
lns status
Reticulum Status
================
Status: Not implemented yet
lncp
lncp is the Reticulum file-transfer tool. It sends a file to a
destination or listens for incoming transfers, and is wire-compatible
with Python’s rncp — an lncp listener accepts an rncp sender and
vice versa. The same engine is also reachable as
lns cp.
Reticulum File Transfer Utility
Usage: lncp [OPTIONS] [FILE] [DESTINATION]
Arguments:
[FILE] File to send (send mode)
[DESTINATION] Destination hash, 32 hex characters (send mode)
Options:
--config <CONFIG> Path to alternative Reticulum config directory
-v, --verbose... Increase verbosity
-q, --quiet... Decrease verbosity
-l, --listen Listen for incoming transfer requests
-w <TIMEOUT> Fetch / transfer phase timeout in seconds
-s, --save <SAVE> Save received files in specified path
-O, --overwrite Allow overwriting received files
-n, --no-auth Accept requests from anyone
-b <ANNOUNCE_INTERVAL> Announce interval (-1=none, 0=once at startup, N=every N sec) [default: 0]
-p, --print-identity Print identity and destination info and exit
-i <IDENTITY> Path to identity file to use
-S, --silent Fully silent: no progress output and no log output at all (equivalent to -qq)
-C, --no-compress Disable automatic compression
-f, --fetch Fetch file from remote listener
-F, --allow-fetch Allow authenticated clients to fetch files
-j, --jail <JAIL> Restrict fetch requests to specified path
-P, --phy-rates Display physical layer transfer rates
-a <ALLOWED> Allow identity hash (can be specified multiple times)
-h, --help Print help
-V, --version Print version
Modes
Send
Give a FILE and a 32-hex-character DESTINATION hash. lncp
establishes a link to the destination and transfers the file:
lncp report.pdf 0123456789abcdef0123456789abcdef
The file is compressed automatically unless you pass -C/--no-compress.
Listen
-l/--listen waits for incoming transfer requests. The listener prints
its own destination hash so the sender knows where to aim:
lncp -l -s ~/incoming
Fetch
-f/--fetch pulls a file from a remote listener instead of pushing to
it (the listener must allow this with -F/--allow-fetch).
Options
| Option | Meaning |
|---|---|
--config <DIR> | Use an alternative Reticulum config directory instead of the default lookup. |
-v/--verbose, -q/--quiet | Raise / lower log verbosity (stackable). |
-l/--listen | Listen for incoming transfer requests. |
-w <TIMEOUT> | Fetch/transfer phase timeout in seconds, counted after the link is established. Default: no timeout — the transfer runs to completion or until interrupted. Slow transports (LoRa) need no artificial cap; set this only for a hard wall-clock bound. |
-s/--save <PATH> | Save received files in this directory (listen mode). |
-O/--overwrite | Allow overwriting existing received files. |
-n/--no-auth | Accept requests from anyone (overrides -a). |
-b <INTERVAL> | Announce interval: -1 never, 0 once at startup, N every N seconds. Default: 0. |
-p/--print-identity | Print the destination hash and identity hash, then exit. |
-i <IDENTITY> | Use this identity file instead of the default. |
-S/--silent | Fully silent: no progress and no log output (equivalent to -qq). |
-C/--no-compress | Disable automatic compression. |
-f/--fetch | Fetch a file from a remote listener (instead of sending). |
-F/--allow-fetch | Allow authenticated clients to fetch files (listen mode). |
-j/--jail <PATH> | Restrict fetch requests to this directory (use with -F). |
-P/--phy-rates | Display physical-layer transfer rates. |
-a <HASH> | Allow a specific identity hash; repeatable to allow several. |
A few options only make sense in listen mode and warn otherwise:
-F/--allow-fetch warns when no -l is given, and -j/--jail warns
without -F (lncp.rs:199-204). -n/--no-auth overrides any -a
allow-list (lncp.rs:213-214).
Identity and authorisation
-p/--print-identity loads (or generates) the identity and prints the
rncp receive destination hash followed by the identity hash, then
exits (lncp.rs:334-352):
lncp -p
0123456789abcdef0123456789abcdef
Identity : fedcba9876543210fedcba9876543210
By default a listener only accepts senders whose identity hash you have
allowed with -a (repeatable). -n/--no-auth drops that check and
accepts anyone. An -a hash must be 32 hex characters / 16 bytes
(lncp.rs:314-332).
Examples
Send a file
On the receiver, start a listener and note its destination hash:
lncp -l -s ~/incoming -O
On the sender, transfer the file to that hash:
lncp ./report.pdf 0123456789abcdef0123456789abcdef
Listen and receive with authorisation
Allow only one known sender, saving into ~/incoming and showing
physical-layer rates:
lncp -l -s ~/incoming -a fedcba9876543210fedcba9876543210 -P
The sender finds its own identity hash with lncp -p.
lncp needs a running daemon (lnsd or rnsd) on the same shared
instance to reach the mesh — see the
lnsd Quickstart.
lnsd(1)
NAME
lnsd – Reticulum network daemon
SYNOPSIS
lnsd [-c dir] [-s dir] [-v…] [-q…]
DESCRIPTION
lnsd runs the Reticulum network stack as a long-lived daemon process. It is a drop-in replacement for Python’s rnsd. Other programs connect to it via shared instance IPC (Unix abstract socket).
On startup, lnsd reads config from the configuration directory, opens all configured interfaces, and begins routing packets. It keeps running until it receives SIGINT or SIGTERM.
Sending SIGUSR1 prints a diagnostic dump of internal state to stderr.
OPTIONS
- -c, –config dir
- Path to the Reticulum configuration directory. Defaults to
~/.reticulum. The config file is<dir>/config. - -s, –storage dir
- Storage directory path. Defaults to
<config_dir>/storage. - -v, –verbose
- Increase log verbosity. Once for debug, twice for trace.
- -q, –quiet
- Decrease log verbosity. Once for warnings only, twice for errors only.
ENVIRONMENT
- RUST_LOG
- Overrides the verbosity flags. See the
tracing-subscriberdocumentation for filter syntax.
FILES
- ~/.reticulum/config
- Default configuration file (INI format, same as Python Reticulum).
- ~/.reticulum/storage/
- Default storage directory for identities, known destinations, and cached path state.
SIGNALS
- SIGINT, SIGTERM
- Graceful shutdown.
- SIGUSR1
- Dump diagnostic state to stderr.
EXAMPLES
Start with default config and verbose logging:
lnsd -v
Start with a custom config directory:
lnsd --config /etc/reticulum
SEE ALSO
lns(1), lncp(1)
lns(1)
NAME
lns – Reticulum network utility
SYNOPSIS
lns [-c dir] [-v] command [args…]
DESCRIPTION
lns is a multi-tool for interacting with a running Reticulum network. It combines the functionality of Python’s rnstatus, rnpath, rnprobe, and more into a single binary.
GLOBAL OPTIONS
- -c, –config dir
- Path to the Reticulum configuration directory.
- -v, –verbose
- Enable verbose logging.
COMMANDS
lns status
Not yet implemented (stub, prints “Not implemented yet” — tracked as Codeberg #22). Will show status of the Reticulum network by connecting to the running daemon via shared instance, equivalent to Python’s rnstatus.
lns path [destination]
Not yet implemented (stub — tracked as Codeberg #22). Will show or request paths to destinations: without an argument, list all known paths; with a destination hash (hex), request a path to that destination. Equivalent to Python’s rnpath.
lns probe destination
Not yet implemented (stub — tracked as Codeberg #22). Will probe a destination by sending a probe packet and measuring round-trip time, equivalent to Python’s rnprobe.
lns interfaces
Not yet implemented (stub — tracked as Codeberg #22). Will show information about configured network interfaces.
lns identity generate [-o file]
Generate a new Reticulum identity and write it to file.
lns identity show file
Show the hash and public keys of the identity in file.
lns cp [options] [file] [destination]
Copy files over Reticulum, compatible with Python’s rncp. See lncp(1) for the full option reference.
lns connect addr
Open an interactive session to a Reticulum daemon at addr (host:port). Supports link establishment, message exchange, and announce discovery. Type /help in the session for available commands.
lns selftest target [target]
Run integration self-tests through one or two relay nodes. Tests link establishment, channel data, ratchet operation, and bulk transfer.
Options:
- –duration seconds
- Test duration (default: 180).
- –rate n
- Messages per second per direction (default: 1).
- –mode mode
- Which phases to run: all, link, packet, ratchet-basic, ratchet-enforced, bulk-transfer, ratchet-rotation (default: all).
lns diag
Collect a self-contained diagnostic bundle from a running lnsd (or rnsd) for attaching to bug reports: versions/build, the secret-redacted config and configured interfaces, the daemon’s live view via the shared-instance RPC (interface stats, path table, link count), best-effort system info, and an event-log pointer. Printed to stdout by default. Use the global -c/–config to point at the daemon’s config directory.
Secrets are redacted — IFAC passphrase and networkname never appear, and the node identity private key (storage/transport_identity) is never read into the bundle (it is used only to derive the RPC authkey). Queries the daemon doesn’t support (e.g. when run against Python rnsd) are reported as unavailable rather than failing.
Options:
- –output path
- Write the bundle to path instead of stdout (a one-line confirmation is printed to stderr).
- –instance-name name
- Shared-instance name to query (default: from the config, else
default). - –event-log path
- Tail this structured event-log file into the bundle (when lnsd was started with
LEVICULUM_EVENT_LOGset). - –no-rpc
- Skip the daemon RPC queries; emit only the config, versions, and system sections.
EXAMPLES
Show network status:
lns status
Request a path:
lns path a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4
Probe a destination:
lns probe a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4
Generate a new identity:
lns identity generate -o my_identity
SEE ALSO
lnsd(1), lncp(1)
lncp(1)
NAME
lncp – Reticulum file transfer utility
SYNOPSIS
lncp [options] file destination lncp [options] -l [-s dir]
DESCRIPTION
lncp transfers files over the Reticulum network. It is compatible with Python’s rncp. It connects to a running daemon (lnsd or rnsd) via shared instance IPC.
In send mode, lncp sends file to the node identified by destination (a 32-character hex hash). In listen mode (-l), it waits for incoming file transfer requests.
OPTIONS
- file
- File to send (send mode).
- destination
- Destination hash, 32 hex characters (send mode).
- –config dir
- Path to alternative Reticulum configuration directory.
- -v, –verbose
- Increase verbosity. Repeat for more detail.
- -q, –quiet
- Decrease verbosity.
- -l, –listen
- Listen for incoming transfer requests.
- -w seconds
- Sender timeout before giving up (default: 15).
- -s, –save dir
- Save received files in the specified directory.
- -O, –overwrite
- Allow overwriting existing files when receiving.
- -n, –no-auth
- Accept requests from anyone (no authentication).
- -b interval
- Announce interval in seconds. -1 = never, 0 = once at startup, N = every N seconds (default: 0).
- -p, –print-identity
- Print identity and destination info and exit.
- -i file
- Path to identity file to use.
- -S, –silent
- Disable transfer progress output.
- -C, –no-compress
- Disable automatic compression.
- -f, –fetch
- Fetch file from remote listener instead of pushing.
- -F, –allow-fetch
- Allow authenticated clients to fetch files.
- -j path
- Restrict fetch requests to the specified path.
EXAMPLES
Send a file:
lncp myfile.tar.gz a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4
Listen for incoming files and save to a directory:
lncp -l -s ~/received/
Listen with verbose logging, accepting from anyone:
lncp -l -n -v
SEE ALSO
lnsd(1), lns(1)
LNode Firmware: Supported Boards
The LNode firmware turns an nRF52840-based board into a standalone
Reticulum transport node. It runs the same reticulum-core transport
engine that powers the Linux daemon, cross-compiled for Cortex-M4F, and
routes packets between three interfaces: USB serial (HDLC framing to a
host), the SX1262 LoRa radio, and BLE. There is no PC in the data path;
the device is a router in its own right.
The transport engine is the same
reticulum-corelibrary that powers the Linux daemon, compiled for Cortex-M4F. (reticulum-nrf/README.md:4)
On the wire the firmware speaks the RNode LoRa framing protocol, so an
LNode and an RNode interoperate on the same LoRa network. On the host
side it connects to lnsd or rnsd over USB serial with HDLC framing.
On the BLE side it implements the Columba v2.2 protocol for the Columba
Android app. (reticulum-nrf/README.md:6,
reticulum-nrf/src/bin/t114.rs:3-8)
What the firmware does
Each firmware binary registers exactly three Reticulum interfaces and runs an event-driven main loop that dispatches packets between them:
| Interface | ID | Medium | HW MTU |
|---|---|---|---|
serial_usb | 0 | USB CDC-ACM, HDLC framing to host | 564 |
lora_sx1262 | 1 | SX1262 LoRa radio | 255 |
ble | 2 | BLE peripheral, Columba v2.2 | 564 |
(Interface registration and MTUs: reticulum-nrf/src/bin/t114.rs:157-162
and reticulum-nrf/src/bin/rak4631.rs:195-200. The main loop selecting
over the three RX sources plus a timer deadline:
reticulum-nrf/src/bin/t114.rs:256-307.)
Transport routing is enabled in the node builder
(.enable_transport(true)), so an LNode forwards packets and serves
paths for other peers, exactly like a transport-enabled lnsd.
(reticulum-nrf/src/bin/t114.rs:123-128)
Supported boards
| Board | SoC + radio | BLE | Baseboard peripherals |
|---|---|---|---|
| Heltec Mesh Node T114 | nRF52840 + SX1262 | yes (Columba v2.2) | none |
| RAK4631 / WisMesh Pocket V2 | nRF52840 + SX1262 | yes (Columba v2.2) | optional display, GNSS, battery |
Both boards are nRF52840 + SX1262 and both run BLE through the Nordic
S140 SoftDevice. (reticulum-nrf/README.md:1-6,
reticulum-nrf/Cargo.toml:65-77)
Note on BLE: Both firmware entry points register a BLE interface and call
reticulum_nrf::ble::init(reticulum-nrf/src/bin/t114.rs:206-232,reticulum-nrf/src/bin/rak4631.rs:243-270). The Cargosoftdevicefeature — and therefore the BLE stack — is pulled in by both BSP features (bsp-t114 = ["softdevice"],bsp-rak4631 = ["softdevice"],reticulum-nrf/Cargo.toml:102-116).
The optional baseboard peripherals (display, GNSS, battery telemetry)
exist only on the RAK19026 baseboard of the WisMesh Pocket V2 and are
each gated behind their own Cargo feature, so the bare-module build stays
unchanged. (reticulum-nrf/Cargo.toml:52-63,
reticulum-nrf/src/bin/rak4631.rs:274-298)
Cargo features and binaries
Two firmware binaries are defined, one per board family:
[[bin]]
name = "t114"
path = "src/bin/t114.rs"
[[bin]]
name = "rak4631"
path = "src/bin/rak4631.rs"
(reticulum-nrf/Cargo.toml:135-142)
The board-support-package (BSP) features select the runtime for a given
board. Exactly one BSP feature must be enabled per build; a
compile_error! in lib.rs enforces the mutual exclusion.
(reticulum-nrf/Cargo.toml:94-116)
| Feature | Effect | Cite |
|---|---|---|
bsp-t114 | T114 BSP (+ SoftDevice BLE) | Cargo.toml:116 |
bsp-rak4631 | RAK4631 BSP (+ SoftDevice BLE) | Cargo.toml:115 |
display | SSD1306 OLED on baseboard | Cargo.toml:118 |
gnss | NMEA0183 GNSS on baseboard | Cargo.toml:119 |
battery | battery telemetry on baseboard | Cargo.toml:120 |
rak-baseboard | aggregate of display + gnss + battery | Cargo.toml:121 |
The mapping from board to binary + features used by the flash recipes:
| Board | Binary | Features |
|---|---|---|
| Heltec Mesh Node T114 | t114 | bsp-t114 |
| RAK4631 (bare module) | rak4631 | bsp-rak4631 |
| WisMesh Pocket V2 (full baseboard) | rak4631 | bsp-rak4631,rak-baseboard |
(Feature sets as invoked in the just flash, just flash-rak4631, and
just flash-rak4631-pocket recipes: Justfile:278, Justfile:292,
Justfile:304.)
Build target
All firmware builds target the hard-float Cortex-M4 triple:
thumbv7em-none-eabihf
(reticulum-nrf/README.md:15. Add it with rustup target add thumbv7em-none-eabihf.)
Default radio profile
The radio parameters are compiled into the firmware and must match the RNode configuration on the same LoRa network.
| Parameter | Value |
|---|---|
| Frequency | 869.525 MHz (EU ISM band) |
| Spreading factor | SF7 |
| Bandwidth | 125 kHz |
| Coding rate | CR4/5 |
| TX power | 17 dBm |
(reticulum-nrf/README.md:8. The eu_medium profile the firmware loads
at boot: reticulum-nrf/src/lora.rs:124-138, applied at
reticulum-nrf/src/bin/t114.rs:200 and
reticulum-nrf/src/bin/rak4631.rs:239.)
See Flashing for how to build and write these binaries to a board, and Recovery for the bootloader-entry details.
LNode Firmware: Building and Flashing
This page covers the prerequisites, the build, and every just flash*
recipe — what each one does and when to reach for it.
Physical-device steps. The author of this page cannot flash a board, so any step that writes to or resets real hardware is marked derived from source — requires the physical device. The commands themselves are quoted verbatim from the
Justfileandreticulum-nrf/README.md; only the outcome on hardware is un-verified here.
Prerequisites
Install the Rust embedded toolchain, the ARM cross-compiler (needed by
nrf-sdc for C-header bindgen), and add your user to the dialout
group for serial-port access. Log out and back in after the usermod
so the new group membership takes effect.
rustup target add thumbv7em-none-eabihf
rustup component add llvm-tools
sudo apt install gcc-arm-none-eabi
sudo usermod -aG dialout $USER
(reticulum-nrf/README.md:12-19)
--release is mandatory
Always build and flash with --release. The debug profile does not fit
the nRF52840 flash — the image overflows FLASH by several hundred KB at
link time.
The debug profile does not fit the nRF52840 flash (the image overflows FLASH by several hundred KB at link time) — always build and flash with
--release; alljust flash-*recipes already do. (reticulum-nrf/README.md:64-67)
Every just flash* recipe already passes --release, so following the
recipes below keeps you safe. The release profile is size-optimized
(opt-level = "z", lto = true, codegen-units = 1); DWARF debug info
is kept in the .elf (strip = "none", debug = true) for HardFault
post-mortem analysis, but the UF2 only carries loadable sections, so the
debug info does not bloat what lands on the device.
(reticulum-nrf/Cargo.toml:123-133)
The build/flash workflow
The firmware crate reticulum-nrf is its own Cargo workspace, separate
from the repo-root workspace, and is cross-compiled. The flash recipes
therefore cd reticulum-nrf before invoking cargo. (Justfile:274-278)
A plain build (no flash) is:
cargo build --release
(reticulum-nrf/README.md:23)
Flashing wraps cargo run: the runner builds the release binary, then
copies the resulting UF2 onto each board’s UF2 bootloader drive. The
UF2 conversion and copy happen inside the cargo run step — a bare
cargo build produces only the ELF.
Build the firmware with
cargo build --release. Flash withjust flash(from the repo root), which wrapscargo run --release --bin t114. (reticulum-nrf/README.md:23)
Touch-free vs. manual double-tap
For the T114, flashing is touch-free in the common case: the host
opens the board’s transport CDC port at 1200 baud, the firmware
intercepts the line-coding change, writes a retained-register magic, and
soft-resets into the Adafruit UF2 bootloader. No button press.
(reticulum-nrf/README.md:27)
A physical double-tap of RESET is still needed when the firmware on a
specific T114 has crashed or never reached USB init (panic before the
handler is installed, stack overflow, hardware fault). The runner detects
this per device via a UF2-drive-polling timeout and prompts for that
specific board only; the rest of the batch keeps flashing touch-free.
(reticulum-nrf/README.md:38)
The WisMesh Pocket V2 (RAK4631) running stock Meshtastic has no
1200-baud-touch handler and no externally accessible RESET pin, so its
first flash needs either just dfu-rak4631 (a Meshtastic admin
command, below) or the manual needle double-tap in the hidden pinhole.
Once our firmware is on the board, subsequent flashes use the touch path
automatically. (Justfile:287-289, Justfile:306-311. See
Recovery for the pinhole detail.)
The flash recipes
Each recipe below is quoted from the Justfile. The cargo invocation is
derived from source — requires the physical device to actually write
firmware (it builds the same on any host, but only does something useful
with a board attached).
just flash — every T114
Flashes every attached T114 sequentially. Flashing all of them is deliberate: if only one were flashed, a later multi-node test could run against mixed firmware versions. Use this as your default for T114s.
cd reticulum-nrf && cargo run --release --bin t114 --features bsp-t114
(Justfile:277-278; rationale reticulum-nrf/README.md:25)
just flash-one PORT — a single T114
Flashes one T114 by port path or udev symlink. Use it for A/B firmware testing (one board on a new build, one on the old).
just flash-one /dev/leviculum-transport
just flash-one /dev/ttyACM3
Expands to:
cd reticulum-nrf && LEVICULUM_FLASH_ONLY=<PORT> cargo run --release --bin t114 --features bsp-t114
(Justfile:280-285; usage forms reticulum-nrf/README.md:31-36)
just flash-rak4631 — every RAK4631 (bare module)
Flashes every attached RAK4631 / WisMesh Pocket V2 with the bare-module build (no baseboard peripherals).
cd reticulum-nrf && LEVICULUM_USB_PID=0002 LEVICULUM_BOARD_NAME=RAK4631 \
LEVICULUM_UF2_BOARD_ID=WisBlock-RAK4631-Board \
cargo run --release --bin rak4631 --features bsp-rak4631
(Justfile:291-292)
just flash-rak4631-one PORT — a single RAK4631
Flashes one RAK4631 by port path or udev symlink.
just flash-rak4631-one /dev/ttyACM0
just flash-rak4631-one /dev/leviculum-rak-transport
Expands to:
cd reticulum-nrf && LEVICULUM_FLASH_ONLY=<PORT> LEVICULUM_USB_PID=0002 \
LEVICULUM_BOARD_NAME=RAK4631 LEVICULUM_UF2_BOARD_ID=WisBlock-RAK4631-Board \
cargo run --release --bin rak4631 --features bsp-rak4631
(Justfile:294-298)
just flash-rak4631-pocket — WisMesh Pocket V2, full baseboard
Flashes with all RAK19026 baseboard peripherals enabled (display, GNSS,
battery). --features rak-baseboard aggregates the three baseboard
features. Use this for a complete WisMesh Pocket V2.
cd reticulum-nrf && LEVICULUM_USB_PID=0002 LEVICULUM_BOARD_NAME=RAK4631 \
LEVICULUM_UF2_BOARD_ID=WisBlock-RAK4631-Board \
cargo run --release --bin rak4631 --features bsp-rak4631,rak-baseboard
(Justfile:303-304; rak-baseboard aggregate
reticulum-nrf/Cargo.toml:121)
just dfu-rak4631 PORT — DFU entry for stock Meshtastic
Triggers the Adafruit UF2 bootloader on a stock-Meshtastic WisMesh
Pocket V2 in software. Stock Meshtastic has no 1200-bps-touch handler and
the device has no externally accessible RESET pin, so this firmware-side
admin command is the only software-only DFU entry. Needed only for
the first flash from Meshtastic; after our firmware lands,
just flash-rak4631 uses the touch path and this recipe is no longer
needed. Requires the meshtastic CLI on PATH (pip install meshtastic).
just dfu-rak4631 /dev/ttyACM0
Runs:
meshtastic --port /dev/ttyACM0 --enter-dfu
(Justfile:306-314)
A note on disconnecting consumers
Flashing a board takes over its transport serial port. Any running
consumer of that port (for example an active lnsd pointed at it) loses
its connection when the board is flashed. The flash action is explicit
and active; no persistence is promised across it.
(reticulum-nrf/README.md:40)
The device keeps its Reticulum identity in internal flash and preserves
it across firmware updates, so re-flashing does not change the node’s
address. (reticulum-nrf/README.md:42. More in Recovery.)
Verifying the build before you flash
cargo build --release (above) confirms the image links and fits flash.
If you want to lint the firmware as CI does:
just lint-nrf
(Builds both BSP feature sets under clippy with -D warnings:
Justfile:38-40.)
Next: Serial ports for wiring the flashed board into
lnsd.
LNode Firmware: USB Serial Ports
A flashed LNode presents two USB CDC-ACM serial ports to the host. Knowing which is which is the difference between reading a debug log and talking the Reticulum transport protocol.
The two ports
The firmware exposes two CDC-ACM serial ports. The lower-numbered
port is the debug log output; the higher-numbered port is the
Reticulum transport interface that carries HDLC frames. The actual
/dev/ttyACM* numbers depend on what else is plugged into USB.
The firmware exposes two USB CDC-ACM serial ports. The lower-numbered port is the debug log output. The higher-numbered port is the Reticulum transport interface that carries HDLC frames. The actual
/dev/ttyACM*numbers depend on other connected USB devices. (reticulum-nrf/README.md:44-46)
Each CDC-ACM class occupies two USB interfaces (a Communication interface plus a Data interface), so the two ports map onto four USB interface numbers:
| Port | USB interface nums | Carries |
|---|---|---|
| Debug | 00 (comm) + 01 (data) | human-readable log lines |
| Transport | 02 (comm) + 03 (data) | Reticulum HDLC frames |
(reticulum-nrf/udev/99-leviculum.rules, header comment.)
Stable device paths via udev
Because the /dev/ttyACM* enumeration order is not stable, install the
shipped udev rules to get fixed symlinks:
sudo cp udev/99-leviculum.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
(reticulum-nrf/README.md:50-53)
After the next plug-in, the symlinks point at the correct ports regardless of enumeration order. The names are board-family specific, keyed off the per-board USB PID:
| Board | USB VID:PID | Debug symlink | Transport symlink |
|---|---|---|---|
| T114 | 1209:0001 | /dev/leviculum-debug | /dev/leviculum-transport |
| RAK4631 / Pocket V2 | 1209:0002 | /dev/leviculum-rak-debug | /dev/leviculum-rak-transport |
(Symlink names and PIDs: reticulum-nrf/udev/99-leviculum.rules. The
firmware-side USB VID/PID constants:
reticulum-nrf/src/boards/t114.rs:139-140 for 1209:0001,
reticulum-nrf/src/boards/rak4631.rs:126-127 for 1209:0002.)
Multiple boards of the same kind. The short symlinks (
/dev/leviculum-transport) land on whichever device udev sees first. The rules also emit per-serial-number symlinks (/dev/leviculum-transport-<SERIAL>); use those when more than one board of the same family is attached. (reticulum-nrf/udev/99-leviculum.rules, header comment andSYMLINK+="leviculum-transport-%s{serial}"lines.)
Reading the debug port
The debug port is plain text at 115200 baud:
picocom /dev/leviculum-debug -b 115200
(reticulum-nrf/README.md:59-60)
On the debug port you will see the boot banner, the firmware git SHA, the
node identity, and the periodic diagnostics the firmware emits — for
example the LNode started -- identity: … line and the [FW_BUILD]
banner re-emitted every few seconds.
(reticulum-nrf/src/bin/t114.rs:164-168, :323-333.) Do not point
lnsd at the debug port; it carries log text, not HDLC frames.
Pointing lnsd at the transport port
The transport port speaks the RNode LoRa framing protocol over HDLC, the
same wire protocol lnsd/rnsd use for an RNode. Configure it as an
RNodeInterface (the dedicated single-radio RNode driver) with its
port set to the transport symlink.
The radio parameters in the config must match the firmware’s
compiled-in defaults (the EU medium profile), otherwise the two sides
talk past each other on the air. (reticulum-nrf/README.md:8;
reticulum-nrf/src/lora.rs:124-138.)
[interfaces]
[[LNode T114]]
type = RNodeInterface
enabled = yes
port = /dev/leviculum-transport
frequency = 869525000
bandwidth = 125000
txpower = 17
spreadingfactor = 7
codingrate = 5
The key names and types come from the [[RNode Interface]] config
schema (port, frequency, bandwidth, txpower, spreadingfactor,
codingrate; docs/src/rnode-protocol.md:674-684). The values above are
the firmware’s compiled defaults: 869.525 MHz, BW 125 kHz, 17 dBm, SF7,
CR4/5 (reticulum-nrf/src/lora.rs:124-138).
For a RAK4631 / WisMesh Pocket V2 the only change is the port:
[[LNode Pocket V2]]
type = RNodeInterface
enabled = yes
port = /dev/leviculum-rak-transport
frequency = 869525000
bandwidth = 125000
txpower = 17
spreadingfactor = 7
codingrate = 5
After editing /etc/reticulum/config, restart the daemon so it picks up
the new interface:
sudo systemctl restart lnsd
(Same restart flow as any config change; see the lnsd Quickstart.)
Confirming the link came up
Run the standard health-check and look for the new interface in the
interface_stats section with status=up and non-zero counters once
LoRa traffic flows:
lns diag --config /etc/reticulum
(lns diag usage and the interface_stats reading are described in the
lnsd Quickstart.)
For the full key-by-key reference of the [[RNode Interface]] section
and the meaning of the optional keys (flow_control, airtime_limit_*,
callsign beaconing), see the Configuration
chapter and docs/src/rnode-protocol.md.
LNode Firmware: Bootloader Entry and Recovery
The nRF52840 boards use the Adafruit UF2 bootloader: it appears as a
mass-storage drive, and writing a .uf2 file to that drive flashes the
device. This page covers how to enter that bootloader (touch-free and
manual), the board-specific caveats, what survives a re-flash, and what
to do when USB stays dark.
Physical-device steps. The author of this page cannot operate a board. Every step that presses a button, taps a pinhole, or observes a drive appearing is derived from source — requires the physical device. The commands and mechanisms are quoted from
reticulum-nrf/README.mdand theJustfile; only the hardware outcome is un-verified here.
Entering the UF2 bootloader
Touch-free (1200-baud), the common case for T114
When the LNode firmware is already running, the host can drop it into the bootloader without any physical interaction: it opens the board’s transport CDC port at 1200 baud, the firmware intercepts the line-coding change, writes a retained-register magic value, and soft-resets into the Adafruit UF2 bootloader.
The host opens each T114’s transport CDC port at 1200 baud, the firmware intercepts the line-coding change, writes a retained-register magic, and soft-resets into the Adafruit UF2 bootloader. No physical button press required. (
reticulum-nrf/README.md:27)
All just flash* recipes use this path automatically when the device is
running our firmware. (derived from source — requires the physical
device.)
Manual RESET double-tap, the fallback
A physical double-tap of the RESET button forces the UF2 bootloader
regardless of firmware state. You need it when the firmware on a specific
board has crashed or never reached USB init — a panic before the
1200-baud handler is installed, a stack overflow, or a hardware fault. In
a just flash batch the runner detects this per device via the
UF2-drive-polling timeout and prompts for that specific board only; the
rest of the batch keeps flashing touch-free.
the firmware on a specific T114 has crashed or never reached USB init (panic before the handler is installed, stack overflow, hardware fault). The runner detects this per device via the UF2-drive-polling timeout and prompts for that specific T114 only. (
reticulum-nrf/README.md:38)
(derived from source — requires the physical device.)
WisMesh Pocket V2 (RAK4631): the hidden-pinhole caveat
The RAK WisMesh Pocket V2 has no externally accessible RESET pin, so the ordinary double-tap-the-button trick does not apply. On this board:
-
First flash from stock Meshtastic. Stock Meshtastic has no 1200-baud-touch handler, so the touch-free path does not work yet. Use the software DFU command instead:
just dfu-rak4631 /dev/ttyACM0which runs
meshtastic --port /dev/ttyACM0 --enter-dfu. This firmware-side admin command is the only software-only DFU entry on a board with no accessible RESET pin. Requires themeshtasticCLI (pip install meshtastic). (Justfile:306-314) -
Manual fallback. Where the software command is unavailable, the bootloader is reached by a needle double-tap in the hidden pinhole — there is no visible reset button; the reset contact is reachable only through a small pinhole, double-tapped with a needle. (This pinhole detail comes from project field notes, not from the firmware source; the source confirms only that the device “has no externally accessible RESET pin”,
Justfile:307-308.) -
After our firmware lands, subsequent flashes use the touch handler in
src/usb.rsand the DFU recipe is no longer needed. (Justfile:309-310)
Do not flash foreign nRF52 firmware onto the Pocket V2 without a recovery plan. Project field experience is that prebuilt third-party nRF52 firmware may not boot on this RAK board (USB stays dark). Because the only software DFU entry is firmware-side, a board that boots into a non-responsive image and exposes no RESET pin can be hard to recover. (This caveat is project knowledge; it is not stated in the firmware source, which documents only the missing RESET pin and the firmware-side DFU command.)
All steps in this section are derived from source / project notes — requires the physical device.
Identity persistence across updates
A re-flash does not change the node’s Reticulum address. The device stores its Reticulum identity in internal flash and preserves it across firmware updates.
The device stores its Reticulum identity in internal flash and preserves it across firmware updates. (
reticulum-nrf/README.md:42)
Mechanically, the firmware loads the identity from a dedicated flash page at boot and only generates (and saves) a new one when none is present:
if id_store.load() => Some(identity) -> "Identity loaded from flash"
else -> generate new, then save
(reticulum-nrf/src/bin/t114.rs:118-154,
reticulum-nrf/src/bin/rak4631.rs:155-192. The identity lives on the
board’s identity_flash_page, e.g. 0xEC000 on the T114,
reticulum-nrf/src/boards/t114.rs:144.) Flashing new firmware rewrites
the program region but leaves that page intact, so the node keeps its
address. You can confirm the loaded identity on the debug port: the boot
log prints Identity loaded from flash and an [IDENTITY] line with the
full hash (reticulum-nrf/src/bin/t114.rs:170-174).
When USB stays dark
If the board enumerates nothing on USB after a flash or a bad image:
-
Force the bootloader manually. On a T114, double-tap RESET to get the UF2 drive regardless of the running image (
reticulum-nrf/README.md:38). On a Pocket V2, use the hidden-pinhole needle double-tap (see above) — the board has no accessible RESET pin (Justfile:307-308). -
Re-flash the known-good LNode firmware once the UF2 drive appears:
just flash(T114) orjust flash-rak4631/just flash-rak4631-pocket(RAK4631). See Flashing. -
Read the debug port at 115200 baud to see why it crashed — the firmware replays the previous boot’s HardFault/panic post-mortem and the persistent log on the next boot:
picocom /dev/leviculum-debug -b 115200Look for
[HARDFAULT_PMRT],[PANIC_PMRT], and[PERSISTENT_LOG]lines (reticulum-nrf/src/bin/t114.rs:77-110;reticulum-nrf/README.md:59-60).
(All hardware steps: derived from source / project notes — requires the physical device.)
ESP32 RNodes vs. nRF52 LNodes. The bricking risk above is specific to the nRF52 LNodes. The ESP32-based RNodes (LilyGO T-Beam) have a mask-ROM download bootloader and cannot be bricked: a failed flash is always recoverable by re-running the flash recipe. The nRF52 LNodes (T114, RAK4631) are different — a bad external image can leave the device USB-dark, which is why a recovery plan matters here. (
Justfile:316-320)
Building on Leviculum in Rust: Choosing a Layer
Leviculum is a Rust workspace, not a single crate. The Reticulum stack is split into layers so that the same protocol engine can run on a tokio server, a bare-metal nRF52 radio, or behind a C ABI. As an application developer your first decision is which layer you build against. This chapter explains the four crates, the dependency direction between them, and gives a decision table.
The companion chapters are the Rust API tutorial (a
hands-on reticulum-std walkthrough), the Rust API reference
(verified signatures of the key types), and Embedded development
(building on reticulum-core directly). If you are writing C rather than Rust,
the C API overview and How-To are
your counterparts to those chapters.
The four layers
reticulum-ffi (C ABI) reticulum-nrf (nRF52 firmware)
│ │
▼ │
reticulum-std (std, tokio) │
│ │
▼ ▼
reticulum-core (no_std, sans-IO)
The dependency direction is strict and one-way. reticulum-std builds on
reticulum-core; reticulum-ffi wraps reticulum-std; reticulum-nrf wraps
reticulum-core directly (it never pulls in std or tokio). Nothing depends on
a layer above it.
reticulum-core — the no_std, sans-IO engine
reticulum-core is the protocol. It is no_std (it pulls in alloc, but not
the standard library), performs no I/O of its own, and owns no runtime. It is
sans-IO: you feed it received bytes, it returns a
TickOutput describing the
packets to send and the events that occurred, and you dispatch those yourself.
Time, persistence, and the network are abstracted behind three traits —
Clock, Storage,
and Interface — that you implement for your
platform.
Build against reticulum-core when you have your own runtime or event loop and
do not want tokio: embedded firmware, an integration into a different async
executor, a simulator, or a host program that wants byte-level control. See
Embedded development.
reticulum-std — the full std/tokio application layer
reticulum-std is what most Rust applications use. It supplies the platform
pieces reticulum-core abstracts: a SystemClock, file-backed storage with
Python-compatible on-disk formats, and concrete interfaces (TCP client and
server, UDP, AutoInterface for LAN discovery, RNode/LoRa, raw serial). On top of
those it runs the sans-IO core inside a tokio event loop and exposes an async,
handle-based API: build a node with ReticulumNodeBuilder,
start() it, take an EventReceiver,
and use LinkHandle / PacketSender
to send.
Build against reticulum-std when you are writing a normal Rust program on
Linux/macOS that talks to a Reticulum mesh. This is the path the
tutorial and the examples under
reticulum-std/examples/ take.
reticulum-ffi — the C ABI wrapper
reticulum-ffi exposes reticulum-std through a C-compatible ABI: opaque
handles, integer error codes, a pollable event fd. It is the layer behind
leviculum.h and libleviculum.so. If you are writing Rust you do not use it —
you use reticulum-std directly, which is what reticulum-ffi itself does
internally. It exists so that non-Rust programs (C, and anything that can call a
C library) get the same engine.
If your application is in C, stop here and read the C API overview and How-To instead; they are the C counterpart to this Rust documentation.
reticulum-nrf — the reference firmware
reticulum-nrf is standalone firmware for nRF52 boards (the T114 and RAK4631
LoRa nodes), built with the Embassy async embedded
framework. It is version 0.4.0, targets thumbv7em-none-eabihf, and depends on
reticulum-core directly with default-features = false — no std, no tokio.
It is both a usable firmware and the worked reference for how to drive the
sans-IO core on bare metal; the embedded chapter walks through its
main loop.
You do not “build on” reticulum-nrf the way you build on a library; you fork it
or read it as the canonical example of a reticulum-core integration on a real
device.
Decision table
| You are building… | Use | Why |
|---|---|---|
| A Linux/macOS app or daemon talking to a mesh | reticulum-std | Async handle API, real interfaces, file storage, tokio loop already wired |
A drop-in tool reusing a running lnsd/rnsd | reticulum-std | connect_to_shared_instance over the shared-instance IPC |
| A relay / transport node | reticulum-std | enable_transport(true), see relay_daemon.rs |
| A C program (any non-Rust language with C FFI) | reticulum-ffi | Stable C ABI, opaque handles, pollable fd — see the C API chapters |
| Firmware on an nRF52 LoRa board | reticulum-nrf | Reference firmware; fork or adapt it |
| Firmware on a different MCU / a custom async runtime | reticulum-core | Implement Clock/Storage/Interface, drive the sans-IO loop yourself |
| A simulator or byte-level test harness with no I/O | reticulum-core | Feed bytes, inspect TickOutput, no runtime imposed |
Adding the dependency
None of these crates are published on crates.io. Depend on them by path (in a
workspace checkout) or by git. For a reticulum-std application:
# Path, when your crate lives next to the libreticulum checkout
[dependencies]
reticulum-std = { path = "../libreticulum/reticulum-std" }
tokio = { version = "1", features = ["full"] }
# Or by git
# reticulum-std = { git = "https://codeberg.org/…/libreticulum" }
For embedded work depend on reticulum-core instead, with default features off:
[dependencies]
reticulum-core = { path = "../libreticulum/reticulum-core", default-features = false }
The workspace is edition 2021, version 0.7.0 (the reticulum-nrf firmware
tracks its own 0.4.0), and licensed AGPL-3.0-or-later.
Rust API Tutorial: Building on reticulum-std
This chapter builds a small application on reticulum-std, the std/tokio layer.
By the end you will have created a node, attached an interface, registered a
destination, sent both a single packet and link data, and consumed
NodeEvents. Every snippet is
adapted from a real example under reticulum-std/examples/; each step names the
file it comes from so you can read the full program. For exact signatures of
everything used here, see the Rust API reference.
If you have not yet decided that reticulum-std is the right layer, read
Choosing a layer first.
Setup
Add the dependency and tokio. The crates are not on crates.io, so use a path (workspace checkout) or git:
[dependencies]
reticulum-std = { path = "../libreticulum/reticulum-std" }
tokio = { version = "1", features = ["full"] }
tracing-subscriber = "0.3"
The examples all assume a running Reticulum daemon to attach to. Start a Python
rnsd (or a Leviculum lnsd) listening on 127.0.0.1:4242, then run an example
with, for instance, cargo run --example simple_send.
Step 1: build and start a node
The entry point is ReticulumNodeBuilder.
You add interfaces on the builder, call build().await, then start().await.
This is the opening of every example; here it is from simple_send.rs:
use reticulum_std::driver::ReticulumNodeBuilder;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt::init();
// Build a node with a TCP interface to a local daemon.
let mut node = ReticulumNodeBuilder::new()
.add_tcp_client("127.0.0.1:4242".parse()?)
.build()
.await?;
node.start().await?;
// ... use the node ...
node.stop().await?;
Ok(())
}
build() loads or generates the node’s transport identity (persisted under the
storage path) and prepares interfaces, but does not run anything. start()
spawns the tokio event loop and brings the interfaces online. stop() flushes
state and tears the loop down. If you are constructing a node outside an async
context, build_sync() is the non-async equivalent of build().
Other interfaces are added the same way:
add_tcp_server(addr), add_udp_interface(listen, forward),
add_auto_interface() (IPv6 multicast LAN discovery), and
add_rnode_interface(...) for LoRa. A relay node adds enable_transport(true),
as in relay_daemon.rs:
#![allow(unused)]
fn main() {
// Adapted from relay_daemon.rs
let mut node = ReticulumNodeBuilder::new()
.enable_transport(true)
.add_tcp_client(peer)
.build()
.await?;
}
Step 2: take the event receiver and consume events
Everything inbound — announces, paths, link lifecycle, link data — reaches you as
NodeEvent values on an
EventReceiver. Take it once with
take_event_receiver() and call recv().await in a loop. From simple_send.rs:
#![allow(unused)]
fn main() {
let mut events = node
.take_event_receiver()
.ok_or("Failed to get event receiver")?;
while let Some(event) = events.recv().await {
println!("Received event: {:?}", event);
}
}
recv() behaves like a tokio::sync::mpsc::Receiver::recv (it is cancel-safe in
tokio::select!) and returns None only once the node has shut down. The
echo_server.rs example shows the real shape: match on the variants you care
about and ignore the rest.
#![allow(unused)]
fn main() {
use reticulum_std::NodeEvent;
// Adapted from echo_server.rs
loop {
tokio::select! {
Some(event) = events.recv() => match event {
NodeEvent::LinkEstablished { link_id, is_initiator } => {
println!("link up: {:02x?} (we initiated: {})",
&link_id.as_bytes()[..4], is_initiator);
}
NodeEvent::LinkDataReceived { link_id, data } => {
println!("{} bytes on {:02x?}: {:?}",
data.len(), &link_id.as_bytes()[..4],
String::from_utf8_lossy(&data));
}
NodeEvent::MessageReceived { link_id, msgtype, sequence, data } => {
println!("msg type 0x{:04x} seq {} on {:02x?}",
msgtype, sequence, &link_id.as_bytes()[..4]);
}
NodeEvent::AnnounceReceived { announce, interface_index } => {
println!("announce from {:02x?} on iface {}",
&announce.destination_hash().as_bytes()[..4], interface_index);
}
other => println!("other: {:?}", other),
},
_ = tokio::signal::ctrl_c() => break,
}
}
}
Note the two receive variants. MessageReceived is the channel-multiplexed path
(sequenced, retransmitted) most link applications use; LinkDataReceived is the
lower-level raw-link-packet path (for example a Python peer calling
RNS.Packet(link, data).send()). The chat.rs example handles both.
Step 3: register and announce a destination
To be reachable you register a local destination and announce it. A destination
is built from your identity, a direction, a type, an app name, and aspect
strings. This is from the api module’s own test, which is the most compact
worked registration in the tree:
#![allow(unused)]
fn main() {
use reticulum_std::{Destination, Direction, DestinationType, generate_identity};
let id = generate_identity();
let dest = Destination::new(
Some(id),
Direction::In,
DestinationType::Single,
"leviculum-test",
&["api"],
)?;
let dh = *dest.hash(); // 16-byte DestinationHash, read before moving dest
node.register_destination(dest); // consumes dest
// Announce it; the optional payload rides along in the announce.
node.announce_destination(&dh, Some(b"hi")).await?;
}
Read dest.hash() before calling register_destination, which takes the
Destination by value. Incoming (Direction::In) destinations are auto-accepted
for links by the core (Python-RNS parity): when a peer opens a link to one, the
stack accepts and proves it automatically and you see a LinkEstablished event —
there is no separate accept call.
Step 4: send a single packet
For fire-and-forget delivery use a PacketSender,
the single-packet handle. A path to the destination must already be known (learn
it from an announce, or call request_path). Adapted from the PacketSender
doctest in driver/sender.rs:
#![allow(unused)]
fn main() {
let endpoint = node.packet_sender(&dest_hash);
let _packet_hash = endpoint.send(b"Hello!").await?;
}
send returns the truncated packet hash, which you can match against a later
PacketDeliveryConfirmed event if the destination proves delivery.
Step 5: open a link and send on it
A link is an encrypted session. Open one with connect, passing the destination
hash and its 32-byte Ed25519 signing key (the signing half of the peer’s
identity, learned from its announce). You get back a
LinkHandle. Adapted from the LinkHandle
doctest in driver/stream.rs:
#![allow(unused)]
fn main() {
let handle = node.connect(&dest_hash, &signing_key).await?;
// The handle is usable immediately, but the link is not yet established.
// Watch for NodeEvent::LinkEstablished on the event receiver before relying
// on delivery, then send:
handle.send(b"Hello!").await?;
}
connect returns as soon as the link request is dispatched; the link is pending
until a LinkEstablished event fires for its link_id. send absorbs pacing and
busy conditions by retrying internally; try_send is the non-blocking variant
that surfaces backpressure instead. Responses arrive as MessageReceived /
LinkDataReceived events on the receiver you took in step 2. Close with
handle.close().await when done.
On the responder side you do not call connect. Once a LinkEstablished event
fires for a link you did not initiate (is_initiator == false), the link is
already live; mint a writable handle for it with node.link_handle(&link_id) and
send on that.
Where to go next
simple_send.rsandecho_server.rs— the minimal node + event loop.chat.rs— both receive variants, node status (active_link_count,pending_link_count).relay_daemon.rs— a transport node andtransport_stats().link_test.rs/link_integration_test.rs— these drop down toreticulum-core’sLinkdirectly against a Pythonrnsd, useful if you want to see the wire-level handshake rather than the high-level handle API.
The full method list of every type is in the generated rustdoc. Build it with:
cargo doc --no-deps --open -p reticulum-std
For verified signatures of the types used above, continue to the Rust API reference.
Rust API Reference
This chapter is a reference for the key entry points and core value types of the
Leviculum Rust API, organized by type. Each signature carries a file:line
citation to the source as of this writing. It is deliberately not exhaustive:
the complete per-type method list is generated rustdoc (see
Full rustdoc at the end). Use this chapter to orient, then
rustdoc for the long tail.
The hands-on introduction is the tutorial; the layer overview is Choosing a layer.
All reticulum-std types are re-exported from the crate root
(reticulum-std/src/lib.rs:35-57), so use reticulum_std::{NodeEvent, LinkHandle, …} works without naming submodules.
reticulum-std (std / tokio)
Reticulum
The configuration-driven entry point, wrapping a ReticulumNode. Defined at
reticulum-std/src/reticulum.rs:13. Use this when your node is described by a
Config (an INI file or a programmatic Config); use ReticulumNodeBuilder
when you assemble interfaces in code.
| Signature | Purpose |
|---|---|
fn new() -> Result<Self> — reticulum.rs:22 | Build from the default config path, or defaults if absent |
fn with_config(config: Config) -> Result<Self> — reticulum.rs:37 | Build from an explicit Config |
fn with_config_daemon(config: Config) -> Result<Self> — reticulum.rs:55 | Like with_config but with no application event channel (daemon mode); take_event_receiver() then returns None |
async fn start(&mut self) -> Result<()> — reticulum.rs:67 | Spawn the event loop |
async fn stop(&mut self) -> Result<()> — reticulum.rs:73 | Stop and persist |
fn is_running(&self) -> bool — reticulum.rs:80 | Whether the loop is running |
fn config(&self) -> &Config — reticulum.rs:85 | Borrow the active config |
fn take_event_receiver(&mut self) -> Option<EventReceiver> — reticulum.rs:110 | Take the event stream, once |
ReticulumNodeBuilder
The programmatic builder. Defined at reticulum-std/src/driver/builder.rs:34;
re-exported as reticulum_std::ReticulumNodeBuilder. Each setter consumes and
returns self.
| Signature | Purpose |
|---|---|
fn new() -> Self — builder.rs:74 | Builder with defaults |
fn identity(self, identity: Identity) -> Self — builder.rs:113 | Pin an explicit identity (else one is generated/persisted) |
fn add_tcp_client(self, addr: SocketAddr) -> Self — builder.rs:155 | Connect outward to a Reticulum node |
fn add_tcp_server(self, addr: SocketAddr) -> Self — builder.rs:168 | Listen for inbound connections |
fn add_udp_interface(self, listen: SocketAddr, forward: SocketAddr) -> Self — builder.rs:182 | One datagram per packet |
fn add_rnode_interface(self, port: String, frequency: u64, bandwidth: u32, spreading_factor: u8, coding_rate: u8, tx_power: i8) -> Self — builder.rs:198 | LoRa interface; required radio settings |
fn add_serial_interface(self, port: String, speed: u32, databits: u8, parity: String, stopbits: u8) -> Self — builder.rs:222 | KISS over raw serial |
fn add_auto_interface(self) -> Self — builder.rs:246 | IPv6 multicast LAN discovery |
fn enable_transport(self, enabled: bool) -> Self — builder.rs:281 | Act as a relay/forwarder |
fn config(self, config: Config) -> Self — builder.rs:129 | Use a pre-loaded Config |
fn config_file(self, path: PathBuf) -> Self — builder.rs:139 | Load an INI config file |
fn storage_path(self, path: PathBuf) -> Self — builder.rs:147 | Identity / known-destinations / ratchet store dir |
fn connect_to_shared_instance(self, name: impl Into<String>) -> Self — builder.rs:322 | Attach to a running lnsd/rnsd instead of bringing up own interfaces |
fn without_events(self) -> Self — builder.rs:105 | Daemon mode: no application event channel |
async fn build(self) -> Result<ReticulumNode, Error> — builder.rs:518 | Build the node (not yet running) |
fn build_sync(self) -> Result<ReticulumNode, Error> — builder.rs:389 | Same as build, outside an async context |
ReticulumNode
The running node. Defined at reticulum-std/src/driver/mod.rs:412; re-exported
as reticulum_std::ReticulumNode. Selected methods:
| Signature | Purpose |
|---|---|
async fn start(&mut self) -> Result<(), Error> — driver/mod.rs:575 | Spawn the event loop, bring interfaces up |
async fn stop(&mut self) -> Result<(), Error> — driver/mod.rs:1123 | Stop and flush |
fn is_running(&self) -> bool — driver/mod.rs:1176 | Loop state |
fn register_destination(&self, destination: Destination) — driver/mod.rs:1184 | Make a local destination reachable (consumes it) |
async fn announce_destination(&self, dest_hash: &DestinationHash, app_data: Option<&[u8]>) -> … — driver/mod.rs:1590 | Announce a registered destination |
async fn connect(&self, dest_hash: &DestinationHash, dest_signing_key: &[u8; 32]) -> Result<LinkHandle, Error> — driver/mod.rs:1202 | Open a link; returns a pending handle |
fn link_handle(&self, link_id: &LinkId) -> LinkHandle — driver/mod.rs:1235 | Writable handle for an already-established inbound link |
fn packet_sender(&self, dest_hash: &DestinationHash) -> PacketSender — driver/mod.rs:1843 | Single-packet send handle |
async fn send_single_packet(&self, …) -> … — driver/mod.rs:1797 | Send one unreliable datagram |
fn take_event_receiver(&mut self) -> Option<EventReceiver> — driver/mod.rs:1251 | Take the event stream, once |
fn identity_hash(&self) -> [u8; 16] — driver/mod.rs:1357 | The node’s own identity hash |
fn has_path(&self, dest_hash: &DestinationHash) -> bool — driver/mod.rs:1402 | Whether a path is known |
fn hops_to(&self, dest_hash: &DestinationHash) -> Option<u8> — driver/mod.rs:1443 | Hop count to a destination |
async fn request_path(&self, dest_hash: &DestinationHash) -> Result<(), Error> — driver/mod.rs:1427 | Send a PATH_REQUEST; result arrives as PathFound |
fn get_identity(&self, dest_hash: &DestinationHash) -> Option<Identity> — driver/mod.rs:1411 | Identity learned from an announce (its signing key feeds connect) |
fn transport_stats(&self) -> TransportStats — driver/mod.rs:1537 | rnstatus-style counters |
fn is_transport_enabled(&self) -> bool — driver/mod.rs:1857 | Relay mode flag |
The stable, curated facade reticulum_std::api (reticulum-std/src/api/mod.rs:55
NodeBuilder / :206 Node) re-projects this surface with core internals
hidden; it is what reticulum-ffi wraps. Notable facade-only helpers:
api::generate_identity() (api/mod.rs:30), api::version() (api/mod.rs:37),
api::version_string() (api/mod.rs:46), and Node::connect_with_key
(api/mod.rs:329) / Node::accept_link (api/mod.rs:345).
LinkHandle
Send-only async handle for a link. Defined at reticulum-std/src/driver/stream.rs:45;
re-exported as reticulum_std::LinkHandle. Incoming data is delivered via
NodeEvent, not on the handle.
| Signature | Purpose |
|---|---|
fn link_id(&self) -> &LinkId — stream.rs:72 | The link’s id |
fn is_closed(&self) -> bool — stream.rs:77 | Handle state |
async fn try_send(&self, data: &[u8]) -> Result<(), Error> — stream.rs:86 | Non-blocking send; surfaces Busy / PacingDelay |
async fn send(&self, data: &[u8]) -> Result<(), Error> — stream.rs:108 | Send, retrying pacing/busy internally |
async fn close(&mut self) -> Result<(), Error> — stream.rs:145 | Graceful close (sends LINKCLOSE) |
PacketSender
Send-only async handle for single packets, the single-packet analog of
LinkHandle. Defined at reticulum-std/src/driver/sender.rs:42; re-exported as
reticulum_std::PacketSender.
| Signature | Purpose |
|---|---|
fn dest_hash(&self) -> &DestinationHash — sender.rs:63 | The target destination |
async fn send(&self, data: &[u8]) -> Result<[u8; TRUNCATED_HASHBYTES], Error> — sender.rs:74 | Send one unreliable packet; returns the truncated packet hash. A path must already be known |
EventReceiver and NodeEvent
EventReceiver is the merged event stream, defined at
reticulum-std/src/driver/mod.rs:259. It internally fronts a lossless control
plane and a droppable data plane (Codeberg #71), draining control first.
| Signature | Purpose |
|---|---|
async fn recv(&mut self) -> Option<NodeEvent> — driver/mod.rs:273 | Next event, control plane prioritized; None once shut down. Cancel-safe |
fn try_recv(&mut self) -> Result<NodeEvent, TryRecvError> — driver/mod.rs:298 | Non-blocking receive |
NodeEvent is the event enum, defined in core at
reticulum-core/src/node/event.rs:21 and re-exported as
reticulum_std::NodeEvent. It is #[non_exhaustive], so always include a
catch-all arm. The variants most applications match (field names verbatim from
source):
| Variant | Fields | Source |
|---|---|---|
AnnounceReceived | announce: ReceivedAnnounce, interface_index: usize | event.rs:24 |
PathFound | destination_hash: DestinationHash, hops: u8, interface_index: usize | event.rs:32 |
PacketReceived | destination: DestinationHash, data: Vec<u8>, interface_index: usize | event.rs:59 |
PacketDeliveryConfirmed | packet_hash: [u8; TRUNCATED_HASHBYTES] | event.rs:69 |
LinkEstablished | link_id: LinkId, is_initiator: bool | event.rs:84 |
MessageReceived | link_id: LinkId, msgtype: u16, sequence: u16, data: Vec<u8> | event.rs:95 |
LinkDataReceived | link_id: LinkId, data: Vec<u8> | event.rs:111 |
LinkClosed | (see source) | event.rs:155 |
MessageReceived is the channel-multiplexed (sequenced) receive path;
LinkDataReceived is the raw-link-packet path. The full variant list (resources,
requests/responses, identify, stale/recovered, control-plane overflow) is in
event.rs and in rustdoc.
Config
Configuration, defined at reticulum-std/src/config.rs:11; re-exported as
reticulum_std::Config. pub reticulum: ReticulumConfig (config.rs:14) and
pub interfaces: HashMap<String, InterfaceConfig> (config.rs:17).
| Signature | Purpose |
|---|---|
fn load<P: AsRef<Path>>(path: P) -> Result<Self> — config.rs:315 | Load an INI config (the rnsd/lnsd format) |
fn default_config_dir() -> PathBuf — config.rs:360 | Default config directory |
fn default_config_path() -> PathBuf — config.rs:369 | Default config file path |
reticulum-core (no_std, sans-IO)
The core is the no_std engine the std layer drives. You use these types directly
only when building on reticulum-core — see Embedded development.
All are re-exported from reticulum-core/src/lib.rs:123-143.
NodeCore<R, C, S>
The sans-IO protocol engine, generic over an RNG R: CryptoRngCore, a clock
C: Clock, and storage S: Storage. Defined at
reticulum-core/src/node/mod.rs:143. It never performs I/O; every method that
can produce output returns a TickOutput the
caller must dispatch.
| Signature | Purpose |
|---|---|
fn new(identity: Identity, config: TransportConfig, proof_strategy: ProofStrategy, max_incoming_resource_size: usize, rng: R, clock: C, storage: S) -> Self — node/mod.rs:213 | Construct directly |
fn register_destination(&mut self, dest: Destination) — node/mod.rs:256 | Register a local destination |
fn announce_destination(&mut self, dest_hash: &DestinationHash, app_data: Option<&[u8]>) -> Result<TickOutput, AnnounceError> — node/mod.rs:410 | Build and queue an announce |
fn send_single_packet(&mut self, dest_hash: &DestinationHash, data: &[u8]) -> Result<([u8; TRUNCATED_HASHBYTES], TickOutput), SendError> — node/mod.rs:473 | Build an unreliable data packet |
fn connect(&mut self, dest_hash: DestinationHash, dest_signing_key: &[u8; 32]) -> (LinkId, bool, TickOutput) — node/link_management.rs:185 | Build a link request |
fn send_on_link(&mut self, link_id: &LinkId, data: &[u8]) -> Result<TickOutput, SendError> — node/link_management.rs:504 | Send on an established link |
fn close_link(&mut self, link_id: &LinkId) -> TickOutput — node/link_management.rs:419 | Close a link |
fn handle_packet(&mut self, iface: InterfaceId, data: &[u8]) -> TickOutput — node/mod.rs:1006 | Feed received bytes from an interface |
fn handle_timeout(&mut self) -> TickOutput — node/mod.rs:1098 | Run periodic maintenance (call at the next deadline) |
fn next_deadline(&self) -> Option<u64> — node/mod.rs:1129 | Earliest timer deadline (ms); when to call handle_timeout |
A node is more often built with NodeCoreBuilder (node/builder.rs:38), whose
fn build<R, Clk, S>(self, rng: R, clock: Clk, storage: S) -> NodeCore<R, Clk, S>
(node/builder.rs:168) supplies the platform triple. Setters include
identity, proof_strategy, and enable_transport.
Core TickOutput and Action
TickOutput is what every core method returns. Defined at
reticulum-core/src/transport.rs:138. It is #[must_use] — dropping it silently
loses outbound packets and events.
| Field | Type | Source |
|---|---|---|
actions | Vec<Action> — I/O for the driver to execute | transport.rs:140 |
events | Vec<NodeEvent> — application-visible events | transport.rs:142 |
next_deadline_ms | Option<u64> — when to next call handle_timeout | transport.rs:145 |
Action is the I/O the driver performs, defined at reticulum-core/src/transport.rs:113:
| Variant | Fields | Source |
|---|---|---|
SendPacket | iface: InterfaceId, data: Vec<u8> | transport.rs:115 |
Broadcast | data: Vec<u8>, exclude_iface: Option<InterfaceId> | transport.rs:122 |
The helper dispatch_actions(interfaces: &mut [&mut dyn Interface], actions: Vec<Action>, ifac_configs: &BTreeMap<usize, IfacConfig>) -> DispatchResult
(transport.rs:211) routes Actions to interfaces with broadcast-exclusion and
IFAC wrapping handled in core, so every driver gets it for free.
Value types
Identity — a key pair or public-only identity. Defined at
reticulum-core/src/identity.rs. Re-exported as reticulum_std::Identity.
| Signature | Purpose |
|---|---|
fn generate<R: CryptoRngCore>(rng: &mut R) -> Self — identity.rs:71 | New random identity |
fn from_public_key_bytes(bytes: &[u8]) -> Result<Self, IdentityError> — identity.rs:113 | Public-only identity |
fn from_private_key_bytes(bytes: &[u8]) -> Result<Self, IdentityError> — identity.rs:127 | From the raw 64-byte private key (Python-compatible) |
fn hash(&self) -> &[u8; IDENTITY_HASHBYTES] — identity.rs:155 | The 16-byte identity hash |
fn public_key_bytes(&self) -> [u8; IDENTITY_KEY_SIZE] — identity.rs:160 | 64 bytes: X25519 [0..32], Ed25519 [32..64] |
fn has_private_keys(&self) -> bool — identity.rs:185 | Whether it can sign/decrypt |
fn sign(&self, message: &[u8]) -> Result<…, IdentityError> — identity.rs:190 | Ed25519 sign |
fn verify(&self, message: &[u8], signature: &[u8]) -> Result<bool, IdentityError> — identity.rs:202 | Ed25519 verify |
Destination — a local or remote destination. Defined at
reticulum-core/src/destination.rs. Re-exported as reticulum_std::Destination.
| Signature | Purpose |
|---|---|
fn new(identity: Option<Identity>, direction: Direction, dest_type: DestinationType, app_name: &str, aspects: &[&str]) -> Result<Self, DestinationError> — destination.rs:285 | Construct a destination |
fn hash(&self) -> &DestinationHash — destination.rs:336 | Its 16-byte hash |
fn direction(&self) -> Direction — destination.rs:351 | In / Out |
DestinationHash — a 16-byte address (newtype, destination.rs:158):
fn new(bytes: [u8; TRUNCATED_HASHBYTES]) -> Self (destination.rs:162),
fn as_bytes(&self) -> &[u8; TRUNCATED_HASHBYTES] (destination.rs:167),
fn into_bytes(self) -> [u8; TRUNCATED_HASHBYTES] (destination.rs:172).
Direction (destination.rs:130) and DestinationType (destination.rs:102)
are the small enums passed to Destination::new. Packets are constructed
internally (reticulum_core::packet::Packet); applications work with
destinations and links, not raw packets.
Platform traits
The three abstractions you implement to run the core on a platform. Defined in
reticulum-core/src/traits.rs and re-exported from lib.rs:141.
| Trait | Required methods (selected) | Source |
|---|---|---|
Clock | fn now_ms(&self) -> u64 | traits.rs:162 |
Storage | key-value persistence: has_packet_hash, get_path/set_path, link/announce tables, identities, ratchets (large trait) | traits.rs:196 |
Interface | id, name, mtu, is_online, fn try_send(&mut self, data: &[u8]) -> Result<(), InterfaceError> | traits.rs:97 |
Provided Storage implementations: NoStorage (traits.rs:506, zero-sized
no-op for stubs and stateless devices), MemoryStorage
(reticulum-core/src/memory_storage.rs, BTreeMap-backed with caps), and
EmbeddedStorage (reticulum-core/src/embedded_storage.rs:37, heapless-backed
for flash-constrained targets; fn new() -> Self at embedded_storage.rs:344).
reticulum-std adds a file-backed Storage with Python-compatible on-disk
formats.
Full rustdoc
This chapter covers the load-bearing surface; the exhaustive method list is the generated rustdoc. Build and open it with:
cargo doc --no-deps --open -p reticulum-std # std/tokio layer
cargo doc --no-deps --open -p reticulum-core # no_std core
Embedded Development: Building on reticulum-core
This chapter is for building on reticulum-core directly: embedded firmware, a
custom async runtime, a simulator, or any host program that wants byte-level
control without tokio. The core is no_std (it uses alloc, but not the
standard library) and sans-IO — it performs no I/O and owns no runtime. You feed
it bytes, it hands back a TickOutput,
and you do the I/O. The worked reference is the nRF52 firmware in reticulum-nrf,
cited throughout.
If you can use std and tokio, prefer reticulum-std and read the
tutorial instead — reticulum-std is itself a driver
for this same core. See Choosing a layer for the trade-off.
The dependency
Depend on reticulum-core with default features off. It is not on crates.io, so
use a path or git:
[dependencies]
reticulum-core = { path = "../libreticulum/reticulum-core", default-features = false }
No std, no tokio. You bring your own executor (Embassy, RTIC, a bare loop) and
your own allocator. The reference firmware reticulum-nrf is version 0.4.0,
targets thumbv7em-none-eabihf, and uses Embassy.
The sans-IO contract
The core is a state machine with exactly three ways in, and one way out. The way
out is always a TickOutput (reticulum-core/src/transport.rs:138), carrying
actions to perform, events that occurred, and next_deadline_ms, the time at
which you must next tick the timer. It is #[must_use]: dropping it loses
outbound packets and events.
received bytes ─► handle_packet(iface, data) ─┐
timer expired ─► handle_timeout() ├─► TickOutput { actions, events, next_deadline_ms }
│
└─► you: dispatch actions, react to events,
schedule the next timeout
The three entry points (signatures in the reference):
handle_packet(iface, data)—reticulum-core/src/node/mod.rs:1006. Feed one received frame, tagged with theInterfaceIdit arrived on.handle_timeout()—reticulum-core/src/node/mod.rs:1098. Run periodic maintenance (path expiry, announce rebroadcasts, keepalives, retransmissions). Call it at or beforenext_deadline.next_deadline()—reticulum-core/src/node/mod.rs:1129. The earliest timer deadline in milliseconds, orNoneif no timer is pending. Sleep until this, or until a packet arrives, whichever comes first.
App-initiated operations (register_destination, announce_destination,
connect, send_on_link, send_single_packet) likewise return a TickOutput
you must dispatch.
The driver loop
The shape is: compute the next deadline, wait for whichever of “a packet on any
interface” or “the deadline” happens first, call the matching entry point,
dispatch the resulting actions. This is exactly the reticulum-nrf T114 main
loop (reticulum-nrf/src/bin/t114.rs:256-307), here with three interfaces
(serial, LoRa, BLE) selected over with Embassy’s select4:
#![allow(unused)]
fn main() {
// Adapted from reticulum-nrf/src/bin/t114.rs:256
loop {
let deadline = node
.next_deadline()
.map(Instant::from_millis)
.unwrap_or(Instant::MAX);
match select4(
serial.incoming_rx.receive(),
lora_channels.incoming_rx.receive(),
ble_channels.incoming_rx.receive(),
Timer::at(deadline),
)
.await
{
Either4::First(data) => {
let output = node.handle_packet(InterfaceId(0), &data);
let mut ifaces: [&mut dyn Interface; 3] =
[&mut serial_iface, &mut lora_iface, &mut ble_iface];
dispatch_actions(&mut ifaces, output.actions, &ifac_configs);
}
Either4::Second(data) => {
let output = node.handle_packet(InterfaceId(1), &data);
let mut ifaces: [&mut dyn Interface; 3] =
[&mut serial_iface, &mut lora_iface, &mut ble_iface];
dispatch_actions(&mut ifaces, output.actions, &ifac_configs);
}
Either4::Third(data) => {
let output = node.handle_packet(InterfaceId(2), &data);
let mut ifaces: [&mut dyn Interface; 3] =
[&mut serial_iface, &mut lora_iface, &mut ble_iface];
dispatch_actions(&mut ifaces, output.actions, &ifac_configs);
}
Either4::Fourth(()) => {
let output = node.handle_timeout();
let mut ifaces: [&mut dyn Interface; 3] =
[&mut serial_iface, &mut lora_iface, &mut ble_iface];
dispatch_actions(&mut ifaces, output.actions, &ifac_configs);
}
}
}
}
Three things to notice:
next_deadline()drives the timer. MapNoneto “wait forever” (Instant::MAX) so you wake only when something actually needs doing — there is no fixed tick rate.InterfaceId(n)tags the source. The index you pass tohandle_packetmust match the interface’s ownid(), so the core’s routing tables and broadcast-exclusion stay consistent.dispatch_actionsdoes the routing. Rather than matching on eachActionyourself, hand the wholeactionsvec plus your&mut dyn Interfaceslice todispatch_actions(reticulum-core/src/transport.rs:211). Broadcast exclusion, interface selection, and IFAC wrapping live in core, so every driver gets them for free.
This loop ignores output.events because a leaf firmware node has no application
logic to react to them; a richer firmware would drain output.events here the
way the std event loop
drains the EventReceiver.
Building the node
NodeCoreBuilder (reticulum-core/src/node/builder.rs:38) takes the platform
triple — RNG, Clock, and
Storage — in its build call. From the
T114 firmware (reticulum-nrf/src/bin/t114.rs:123-142):
#![allow(unused)]
fn main() {
// Adapted from reticulum-nrf/src/bin/t114.rs:123
let mut builder = NodeCoreBuilder::new()
.enable_transport(true)
.max_incoming_resource_size(8 * 1024)
.respond_to_probes(true);
if let Ok(Some(identity)) = id_store.load() {
builder = builder.identity(identity);
}
let mut node = Box::new(builder.build(rng, EmbassyClock, EmbeddedStorage::new()));
}
build consumes the builder and the platform triple and returns the
NodeCore<R, C, S>.
Implementing the platform traits
Three traits decouple the core from your hardware. Their signatures are in the reference; here is what to supply.
Clock
A monotonic millisecond clock. The whole trait is one required method. The
nRF52 implementation wraps Embassy’s timer
(reticulum-nrf/src/clock.rs):
#![allow(unused)]
fn main() {
use reticulum_core::traits::Clock;
pub struct EmbassyClock;
impl Clock for EmbassyClock {
fn now_ms(&self) -> u64 {
embassy_time::Instant::now().as_millis()
}
}
}
now_secs, has_elapsed, and deadline have default implementations
(reticulum-core/src/traits.rs:167-179); you only provide now_ms. It must be
monotonic.
Interface
The send side of an interface — id, name, mtu, is_online, and the
non-blocking try_send (reticulum-core/src/traits.rs:97). The receive side is
deliberately not in the trait: receiving is platform-specific (an interrupt, a
DMA buffer, an Embassy channel), and you feed received bytes into the core via
handle_packet yourself. try_send returns InterfaceError::BufferFull
(non-fatal, packet dropped — Reticulum is best-effort) or
InterfaceError::Disconnected. A minimal always-ready interface looks like the
test impl in traits.rs:
#![allow(unused)]
fn main() {
use reticulum_core::traits::{Interface, InterfaceError};
use reticulum_core::transport::InterfaceId;
struct MyRadio { /* hardware handle */ }
impl Interface for MyRadio {
fn id(&self) -> InterfaceId { InterfaceId(1) }
fn name(&self) -> &str { "my-radio" }
fn mtu(&self) -> usize { 500 }
fn is_online(&self) -> bool { true }
fn try_send(&mut self, data: &[u8]) -> Result<(), InterfaceError> {
// hand `data` to the radio's TX queue, non-blocking
Ok(())
}
}
}
A constrained medium (LoRa) overrides next_slot_ms
(reticulum-core/src/traits.rs:150) to report the next airtime-fit time, so the
core schedules retries against capacity without knowing any radio physics — the
interface-isolation rule. For a fast link the default
(“always ready”) is correct.
Storage
Key-value persistence for the path table, link table, announce caches,
identities, ratchets, and dedup hashes (reticulum-core/src/traits.rs:196). It
is a large trait; you do not write it from scratch:
NoStorage(reticulum-core/src/traits.rs:506) — zero-sized, every lookup returns nothing. Use it for a stateless node or a smoke test.EmbeddedStorage(reticulum-core/src/embedded_storage.rs:37,EmbeddedStorage::new()at:344) —heapless-backed, fixed-capacity, the production choice for flash-constrained devices. This is what the nRF52 firmware uses.MemoryStorage(reticulum-core/src/memory_storage.rs) — BTreeMap-backed with configurable caps, for hosts with more memory.
Implement Storage yourself only to add real persistence (e.g. to flash); the
file-backed implementation in reticulum-std is the worked example of wrapping
MemoryStorage with disk writes.
Summary
- Depend on
reticulum-corewithdefault-features = false. Nostd, no tokio,allocrequired. - Drive the loop:
next_deadline()→ wait for a packet or the deadline →handle_packet/handle_timeout→dispatch_actions(output.actions). - Implement
Clock(trivial),Interface(send side only — you feed RX in viahandle_packet), and pick aStorage(NoStorage/EmbeddedStorage/MemoryStorage, or your own). - The full worked driver is
reticulum-nrf/src/bin/t114.rs; the full method list iscargo doc --no-deps -p reticulum-core(see the reference).
C API: Overview and Concepts
Leviculum ships a C API so an application can use the Reticulum network stack
the way it uses any normal Unix C library: a clean header, opaque handle
types, integer error codes, and composition with the application’s own event
loop. This chapter explains the model that the How-To and the
API Reference build on. For the design rationale behind these
choices, see the design-of-record at docs/leviculum-api-design.md.
Every symbol is prefixed lev_ (functions) or LEV_ (constants). The header
is leviculum.h, the library is libleviculum.so.
Installing and linking
Once the development package is installed, building against Leviculum is the usual two lines:
#include <leviculum.h>
cc app.c $(pkg-config --cflags --libs leviculum)
The pkg-config call expands to -lleviculum plus the include and library
paths. To build from source and install the header, the shared object (with its
SONAME and dev symlinks), the static archive, and the pkg-config file:
make -C reticulum-ffi install PREFIX=/usr/local # builds, then installs
To link Leviculum statically while glibc stays dynamic, pass --static so
pkg-config adds the archive’s system dependencies, and force the archive:
cc app.c $(pkg-config --cflags leviculum) \
-l:libleviculum.a $(pkg-config --static --libs-only-l leviculum | sed 's/-lleviculum//')
See Installation for the full toolchain setup. The
install is verified end to end (dynamic and static, x86_64 and aarch64) by
scripts/verify-packaging.sh.
Opaque handles
Every complex object is an opaque pointer. The application never sees a struct
layout, so the ABI stays stable across versions. Each handle has a constructor
and a matching free function; _free(NULL) is always a no-op.
| Handle | Represents | Created by | Freed by |
|---|---|---|---|
leviculum_t | a node (runtime, engine, event bridge) | lev_builder_build | lev_free |
lev_builder_t | node configuration before build | lev_builder_new | lev_builder_free |
lev_identity_t | a key pair or public-only identity | lev_identity_generate, lev_identity_from_*, lev_identity_load_file, lev_link_remote_identity | lev_identity_free |
lev_destination_t | a local destination | lev_destination_new | lev_destination_free |
lev_link_t | one link to a peer | lev_connect, lev_connect_with_key, lev_accept_link | lev_link_free |
lev_event_t | one drained event | lev_next_event, lev_wait_event | lev_event_free |
Two builders are single-use: lev_builder_build and lev_register_destination
take the contents of their handle and leave an empty shell that the caller
still frees.
Addresses are not handles. A destination hash, a link id, and an identity hash
are each a fixed 16-byte value (LEV_ADDR_LEN); a resource hash is 32 bytes
(LEV_RESOURCE_HASH_LEN). They cross the boundary as plain uint8_t arrays.
Error handling
Functions that can fail return int: 0 (LEV_OK) on success, a negative
LEV_ERR_* code on failure. Constructors that return a handle return NULL
on failure. Two helpers turn a code into text:
lev_strerror(code)returns a static, never-freed string for the code.lev_last_error()returns a thread-local string with the specific detail of the most recent failing call on the calling thread (which argument, which address). It is owned by the library and must not be freed.
int rc = lev_start(node);
if (rc != LEV_OK) {
fprintf(stderr, "start failed: %s (%s)\n", lev_strerror(rc), lev_last_error());
}
The full code list is in the reference.
Buffers: the read(2) convention
Every function that returns bytes into a caller buffer uses the same shape,
modelled on read(2):
int lev_identity_hash(const lev_identity_t *id,
uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
- The caller owns
bufand passes its capacitycapplus anout_len. - On success the library writes the bytes and sets
*out_lento the count. - If
capis too small (orbufisNULL), nothing is written,*out_lenis set to the required size, and the call returnsLEV_ERR_BUFFER_TOO_SMALL. Passingbuf == NULLis therefore a valid size query.
uint8_t hash[LEV_ADDR_LEN];
uintptr_t len = sizeof(hash);
if (lev_identity_hash(id, hash, sizeof(hash), &len) == LEV_OK) {
/* `hash` holds `len` bytes */
}
The library never hands C a raw pointer to free: all freeing goes through a
typed lev_*_free, which removes C-free-versus-Rust-dealloc mistakes.
Out-parameters for returned values
Status and value are never multiplexed into one return. A call that both can
fail and produces a value returns the int status and writes the value
through an out-parameter:
uint8_t packet_hash[LEV_ADDR_LEN];
int rc = lev_send_datagram(node, dest, data, len, packet_hash, 3000);
lev_link_t *link = NULL;
int rc2 = lev_connect(node, dest, 5000, &link); /* link in *out */
Strings and bytes
- Opaque byte payloads (keys, hashes, datagram data, link data, resource data) are always a pointer plus a length, never NUL-terminated, and may contain zero bytes.
- Human-readable strings the library consumes (storage path, destination
app_name, requestpath) are NUL-terminated UTF-8 C strings. - A destination’s aspects are passed as a
const char *const *array plus a count. - Library-returned static strings (
lev_strerror,lev_last_error,lev_version_string) are NUL-terminated and must not be freed.
The event model: a pollable fd
Everything inbound (received announces, link data, request and response
arrivals, resource progress and completion) reaches the application as events.
A node exposes a single readable file descriptor that the application adds to
its own poll/epoll/select loop:
struct pollfd p = { .fd = lev_event_fd(node), .events = POLLIN };
poll(&p, 1, -1);
lev_event_t *ev;
while (lev_next_event(node, &ev) == LEV_OK && ev) {
switch (lev_event_type(ev)) {
case LEV_EVENT_ANNOUNCE_RECEIVED: /* ... */ break;
case LEV_EVENT_LINK_DATA: /* ... */ break;
}
lev_event_free(ev);
}
The fd is level-triggered: it is readable exactly while the queue is
non-empty. After each wake, drain with lev_next_event until it yields NULL.
lev_wait_event(node, &ev, timeout_ms) is a convenience that blocks for the
next event without your own loop. The event side is single-consumer: do not
call the two drain functions concurrently for the same node.
The fd is owned by the library and closed by lev_free. The shutdown order is
mandatory: stop reacting to the fd, remove it from your loop, then call
lev_free. Polling the fd after lev_free is a use-after-close.
Event handles are fully self-owned (payloads are copied out at dequeue), so an
event stays valid until lev_event_free regardless of later calls. Read its
fields with the typed accessors (lev_event_link_id, lev_event_data,
lev_event_request_id, lev_event_resource_hash, and so on); an accessor that
does not apply to the event type returns LEV_ERR_INVALID_ARG.
Threading and blocking
The tokio runtime is created and owned inside the node and never exposed.
- A
leviculum_tis thread-safe: its methods may be called concurrently from multiple threads. - The event side is single-consumer (above).
- Every potentially-blocking call takes a
timeout_ms(negative means wait forever); on expiry it returnsLEV_ERR_TIMEOUT. The link data path istry_send-first:lev_link_try_sendnever blocks and returnsLEV_ERR_AGAINunder backpressure, whilelev_link_sendretries up to its deadline. lev_free,lev_stop, and the other blocking calls must run on a plain OS thread, never on a worker thread of another runtime (for example a host async runtime); doing so would panic the embeddedblock_on.- The log callback may fire on any internal worker thread and must not call
back into any
lev_*function.
No panic crosses the boundary
Every exported function wraps its body so that an internal Rust panic is caught
and converted to LEV_ERR_PANIC (or NULL for a constructor) instead of
unwinding into C, which would be undefined behaviour. After a caught panic the
affected node should be freed and not reused.
One-time setup and logging
lev_init() performs idempotent process setup (logging subscriber and panic
hook). It is optional, since other entry points run it lazily, but call it
explicitly to configure logging before the first node. Logging is silent by
default; raise it with lev_log_set_level(LEV_LOG_INFO) and route records with
lev_log_set_callback, or leave the default which writes to stderr.
With these conventions in hand, the Tutorial builds a complete,
useful program (levcat, a pipe over the mesh) step by step, the
How-To is the recipe book for every flow, and the
API Reference documents every function.
Tutorial: Build levcat, a Pipe over Reticulum
This tutorial builds one small, complete, genuinely useful program from scratch:
levcat, a bidirectional pipe over the mesh, the netcat of Reticulum. Run it
in two terminals and it is a chat. Feed it a file and it is a file transfer
(levcat connect ... < file). Drop it in a shell pipeline and it carries bytes
between machines, over TCP, over LoRa, over anything Reticulum reaches.
Along the way you learn the patterns every Leviculum C program needs: bring up a
node, announce and discover a destination, open a link, and — the heart of it —
run the node’s event loop inside your own poll(2) loop, alongside your own
file descriptors. After this you can write your own Leviculum program.
This builds on the Overview (opaque handles, the read(2) buffer
convention, the pollable event fd); skim it first. The How-To is the
recipe companion, and the API Reference has every signature. The
finished program is reticulum-ffi/examples/c/levcat.c, compiled and tested in
the repo, so the code here is real, not pseudo-code.
What we build
Two roles share one transport and one steady-state loop:
levcat listen <storage> <bind host:port> # the listening end
levcat connect <storage> <peer host:port> <dest-hex> # the dialing end
The listener registers a destination, announces it, and prints its address. The connector is handed that address, finds a path to it, and opens a link. Once linked, both ends pump stdin to the link and link data to stdout.
1. Skeleton
Start with argument parsing, one-time init, and a signal flag so Ctrl-C exits
cleanly. lev_init() is optional (other calls run it lazily) but it is the
place to set up logging before anything else.
#include <errno.h>
#include <poll.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "leviculum.h"
static volatile sig_atomic_t stop = 0;
static void on_signal(int s) { (void)s; stop = 1; }
int main(int argc, char **argv) {
signal(SIGINT, on_signal);
signal(SIGTERM, on_signal);
lev_init();
if (argc == 4 && strcmp(argv[1], "listen") == 0)
return run_listen(argv[2], argv[3]);
if (argc == 5 && strcmp(argv[1], "connect") == 0)
return run_connect(argv[2], argv[3], argv[4]);
fprintf(stderr, "usage:\n %s listen <storage> <bind host:port>\n"
" %s connect <storage> <peer host:port> <dest-hex>\n",
argv[0], argv[0]);
return 2;
}
2. Bring up a node
A node is built, then started. The builder is an opaque handle you configure and then consume; see reference: node lifecycle and builder. Both roles share this helper, differing only in the interface they add — a TCP server for the listener, a TCP client for the connector.
static leviculum_t *build_start(const char *storage,
void (*configure)(lev_builder_t *, const char *),
const char *arg, lev_identity_t *id) {
lev_builder_t *b = lev_builder_new();
if (!b) return NULL;
if (lev_builder_storage_path(b, storage) != LEV_OK) { lev_builder_free(b); return NULL; }
if (id) lev_builder_identity(b, id);
configure(b, arg); /* add the interface */
leviculum_t *node = lev_builder_build(b);
lev_builder_free(b); /* build empties the builder; still free it */
if (!node) return NULL;
if (lev_start(node) != LEV_OK) { lev_free(node); return NULL; }
return node;
}
static void cfg_server(lev_builder_t *b, const char *addr) { lev_builder_add_tcp_server(b, addr); }
static void cfg_client(lev_builder_t *b, const char *addr) { lev_builder_add_tcp_client(b, addr); }
3. The listening end
The listener owns a destination: an address other nodes can reach. We generate
an identity, register an incoming single destination under the app name
levcat with the aspect pipe, and read back its 16-byte hash. Then we
announce it so the network learns a path, and print the address — to stderr,
because stdout is the data pipe and must stay clean.
lev_identity_t *id = lev_identity_generate();
leviculum_t *node = build_start(storage, cfg_server, bind_addr, id);
const char *aspects[] = {"pipe"};
lev_destination_t *dest =
lev_destination_new(id, LEV_DIRECTION_IN, LEV_DEST_SINGLE, "levcat", aspects, 1);
uint8_t dh[LEV_ADDR_LEN];
size_t dhl = sizeof(dh);
lev_destination_hash(dest, dh, sizeof(dh), &dhl);
lev_register_destination(node, dest);
lev_destination_free(dest);
char hexhash[2 * LEV_ADDR_LEN + 1];
hex(dh, LEV_ADDR_LEN, hexhash); /* lev_hex_encode wrapper */
fprintf(stderr, "destination: %s\n", hexhash);
Now wait for someone to dial in. We re-announce in a loop (so a peer that starts
later still discovers us) and watch for a LEV_EVENT_LINK_REQUEST. When it
arrives we read the link id from the event and accept it. See
How-To: announcing and discovering.
lev_link_t *link = NULL;
while (!stop && !link) {
lev_announce(node, dh, NULL, 0, 2000);
for (int i = 0; i < 3 && !link; i++) {
lev_event_t *ev = NULL;
if (lev_wait_event(node, &ev, 200) != LEV_OK || !ev) continue;
if (lev_event_type(ev) == LEV_EVENT_LINK_REQUEST) {
uint8_t lid[LEV_ADDR_LEN];
size_t l = sizeof(lid);
lev_event_link_id(ev, lid, sizeof(lid), &l);
lev_accept_link(node, lid, 5000, &link);
}
lev_event_free(ev);
}
}
One subtlety: accepting a link does not make it immediately usable for
sending. The responder’s link becomes active only after the initiator’s RTT
exchange, signalled by the responder’s own LEV_EVENT_LINK_ESTABLISHED. Sending
before that returns LEV_ERR_SEND (“link not active”). So we wait for it before
pumping, writing through any data that arrives meanwhile so none is lost:
int active = 0;
while (link && !stop && !active) {
lev_event_t *ev = NULL;
if (lev_wait_event(node, &ev, 200) != LEV_OK || !ev) continue;
int t = lev_event_type(ev);
if (t == LEV_EVENT_LINK_ESTABLISHED) active = 1;
else if (t == LEV_EVENT_LINK_MESSAGE) emit_message(ev); /* don't drop early data */
else if (t == LEV_EVENT_LINK_CLOSED) stop = 1;
lev_event_free(ev);
}
if (active) pump(node, link);
lev_wait_event is the blocking drain we use during setup; the steady-state
loop (pump, below) uses the pollable fd instead. Every event must be freed
with lev_event_free.
4. The dialing end
The connector is given the listener’s address as hex. Decode it to 16 bytes,
bring up a node with a TCP client interface, and wait for a path: the listener’s
announce arrives over the link and installs one. lev_request_path nudges it
along; lev_has_path reports when it is ready. See
reference: paths, connect, and links.
uint8_t dest[LEV_ADDR_LEN];
size_t dlen = sizeof(dest);
lev_hex_decode((const uint8_t *)dest_hex, strlen(dest_hex), dest, sizeof(dest), &dlen);
leviculum_t *node = build_start(storage, cfg_client, peer_addr, NULL);
lev_request_path(node, dest, 2000);
for (int i = 0; i < 300 && lev_has_path(node, dest) != 1; i++) {
lev_event_t *ev = NULL;
if (lev_wait_event(node, &ev, 200) == LEV_OK && ev) lev_event_free(ev);
}
With a path in hand, open the link. lev_connect returns as soon as the request
is sent — the link is usable only after the handshake, which the engine signals
with LEV_EVENT_LINK_ESTABLISHED. Wait for it, then start pumping.
lev_link_t *link = NULL;
lev_connect(node, dest, 8000, &link);
int established = 0;
for (int i = 0; i < 100 && !established; i++) {
lev_event_t *ev = NULL;
if (lev_wait_event(node, &ev, 200) == LEV_OK && ev) {
if (lev_event_type(ev) == LEV_EVENT_LINK_ESTABLISHED) established = 1;
lev_event_free(ev);
}
}
if (established) pump(node, link);
5. The pump loop — the heart of it
Both ends now have a link and run the same loop. This is the pattern that makes
Leviculum composable: the node exposes a single readable file descriptor
(lev_event_fd), so you put it in your own poll(2) set right next to your own
fds. Here that is stdin. One poll waits for either: local input to send, or
a network event to receive. See the event model.
static void pump(leviculum_t *node, lev_link_t *link) {
struct pollfd fds[2];
fds[0].fd = STDIN_FILENO; fds[0].events = POLLIN;
fds[1].fd = lev_event_fd(node); fds[1].events = POLLIN;
while (!stop) {
int r = poll(fds, 2, 1000);
if (r < 0) { if (errno == EINTR) continue; break; }
if (fds[0].revents & POLLIN) { /* local input -> link */
uint8_t buf[CHUNK];
ssize_t n = read(STDIN_FILENO, buf, sizeof(buf));
if (n <= 0) { /* EOF: flush, then close — see below */ return; }
if (lev_link_send(link, buf, (size_t)n, 5000) != LEV_OK) return;
}
if ((fds[1].revents & POLLIN) && drain_to_stdout(node)) return; /* link -> stdout */
}
}
Two details:
- Chunking. A link’s reliable channel has a maximum message size, so we read
stdin in
#define CHUNK 256-byte pieces that fit on any interface.lev_link_sendis the reliable, sequenced send; it blocks up to its deadline, retrying backpressure internally. (The non-blocking sibling islev_link_try_send, which returnsLEV_ERR_AGAINinstead of waiting — see How-To: links and exchanging data.) - Receiving.
lev_link_sendon one side surfaces as aLEV_EVENT_LINK_MESSAGEon the other. We drain every pending event and copy each message’s bytes to stdout, using the read(2)-style accessor (size query, then fill):
static void emit_message(lev_event_t *ev) {
size_t need = 0;
lev_event_data(ev, NULL, 0, &need); /* size query */
uint8_t *d = malloc(need ? need : 1);
size_t got = need;
lev_event_data(ev, d, need, &got); /* fill */
fwrite(d, 1, got, stdout);
fflush(stdout);
free(d);
}
static int drain_to_stdout(leviculum_t *node) {
int closed = 0;
lev_event_t *ev = NULL;
while (lev_next_event(node, &ev) == LEV_OK && ev) {
int t = lev_event_type(ev);
if (t == LEV_EVENT_LINK_MESSAGE) {
emit_message(ev);
} else if (t == LEV_EVENT_LINK_CLOSED) {
closed = 1;
}
lev_event_free(ev);
}
return closed;
}
The fd is level-triggered: it stays readable while the queue is non-empty,
so after each wake we drain with lev_next_event until it yields NULL. The
event side is single-consumer — never drain the same node from two threads.
6. Closing cleanly
When local input ends (Ctrl-D, or the end of a piped file), we are done sending.
Give the reliable channel a moment to deliver the last bytes — draining any
final inbound meanwhile — then close our end. The peer sees LEV_EVENT_LINK_CLOSED
and exits too, so a cat file | levcat connect ... terminates instead of
hanging. This is the if (n <= 0) branch of the pump:
for (int g = 0; g < 10 && !stop; g++) {
struct pollfd ef = {fds[1].fd, POLLIN, 0};
if (poll(&ef, 1, 100) > 0 && drain_to_stdout(node)) break;
}
lev_close_link(link, 2000);
return;
(A production tool would do a real half-close so the reverse direction can keep flowing; we keep it minimal.) Then tear the node down in order — the link first, then the node:
lev_link_free(link); /* NULL-safe; closes the link if still open */
lev_stop(node); /* persists state, stops the loop */
lev_free(node); /* releases the runtime and the event fd */
lev_identity_free(id); /* listener only */
The shutdown order is mandatory: stop reacting to the event fd before
lev_free, which closes it.
7. Build and run it
Compile against the installed library with pkg-config (installing and linking):
cc levcat.c $(pkg-config --cflags --libs leviculum) -o levcat
Open two terminals. In the first, listen:
$ ./levcat listen /tmp/levcat-a 127.0.0.1:4242
destination: a1b2c3d4e5f6... # printed on stderr
In the second, connect with that address, then type on either side:
$ ./levcat connect /tmp/levcat-b 127.0.0.1:4242 a1b2c3d4e5f6...
hello from the other terminal
That is a chat. It is also a pipe — send a file and end with Ctrl-D, or:
# receiver
./levcat listen /tmp/levcat-a 127.0.0.1:4242 > received.tar
# sender
tar c somedir | ./levcat connect /tmp/levcat-b 127.0.0.1:4242 <dest-hex>
Nothing here is TCP-specific. Swap lev_builder_add_tcp_* for
lev_builder_add_rnode (or load a config file) and the same program pipes bytes
across a LoRa mesh.
Where to go next
You have used the core of the API: node setup, announce and discovery, links, and the event loop. From here:
- Bulk data with progress, compression, and metadata:
How-To: resource transfer (and the full
reticulum-ffi/examples/c/lncp.cfile-copy tool). - Lightweight RPC: How-To: request and response.
- Best-effort single packets: How-To: datagrams.
- Inspecting the stack: How-To: diagnostics.
- Every signature and constant: the API Reference.
Full program: reticulum-ffi/examples/c/levcat.c.
C API: How-To, Building Applications
This chapter shows how the functions combine into working programs. It assumes
the model from the Overview: opaque handles, integer error
codes, read(2) buffers, and the pollable event fd. Each recipe gives the
functions involved and a focused snippet; the complete, compiling programs are
the acceptance tests under reticulum-ffi/examples/c/, named per recipe. For a
single program built end to end from these pieces, see the
Tutorial.
Error checks are abbreviated in the snippets for readability. In real code,
check every int return against LEV_OK and report lev_last_error() (see
Errors and logging).
A minimal node
Build a node, attach an interface, start it, and shut it down. The builder is
single-use: lev_builder_build consumes its configuration and you still free
the empty handle.
#include <leviculum.h>
#include <stdio.h>
int main(void) {
lev_init();
printf("leviculum %s\n", lev_version_string());
lev_builder_t *b = lev_builder_new();
lev_builder_storage_path(b, "/var/lib/myapp/reticulum");
lev_builder_add_tcp_client(b, "127.0.0.1:4242"); /* a Reticulum hub */
leviculum_t *node = lev_builder_build(b);
lev_builder_free(b); /* build emptied it */
if (!node) {
fprintf(stderr, "build failed: %s\n", lev_last_error());
return 1;
}
if (lev_start(node) != LEV_OK) {
fprintf(stderr, "start failed: %s\n", lev_last_error());
lev_free(node);
return 1;
}
/* ... run the application ... */
lev_stop(node);
lev_free(node); /* lev_free also stops a still-running node */
return 0;
}
Interfaces are added on the builder: lev_builder_add_tcp_client,
lev_builder_add_tcp_server, lev_builder_add_udp,
lev_builder_add_auto_interface. Use lev_builder_identity to pin a specific
identity (otherwise one is generated), and lev_builder_enable_transport(b, 1)
to act as a relay.
Full program: reticulum-ffi/examples/c/phase_a.c.
Running the event loop
Everything inbound arrives as events. Add lev_event_fd(node) to your loop,
and on each wake drain with lev_next_event until it yields NULL.
#include <poll.h>
int fd = lev_event_fd(node);
for (;;) {
struct pollfd p = { .fd = fd, .events = POLLIN };
poll(&p, 1, -1);
lev_event_t *ev;
while (lev_next_event(node, &ev) == LEV_OK && ev) {
switch (lev_event_type(ev)) {
case LEV_EVENT_ANNOUNCE_RECEIVED: on_announce(ev); break;
case LEV_EVENT_LINK_REQUEST: on_link_request(ev); break;
case LEV_EVENT_LINK_MESSAGE: on_link_message(ev); break;
/* ... */
}
lev_event_free(ev);
}
}
If you do not want to own a loop, block for one event at a time:
lev_event_t *ev = NULL;
if (lev_wait_event(node, &ev, 1000) == LEV_OK && ev) { /* up to 1s */
/* handle ev */
lev_event_free(ev);
}
Rules: the fd is level-triggered (readable while the queue is non-empty); the
two drain functions are single-consumer (one thread at a time); and the
shutdown order is stop reacting to the fd, then lev_free. Reading an event’s
fields uses the typed accessors shown in the recipes below.
Running as or with a daemon
A node need not bring up its own interfaces in code. Three builder calls cover the daemon use cases.
Load an RNS-style config (the same INI rnsd/lnsd read), so interfaces,
transport, and the shared instance come from a file an operator edits. This is
also how a C node reaches LoRa without programmatic radio setup, the config
names an RNodeInterface or SerialInterface and the stack brings it up.
lev_builder_t *b = lev_builder_new();
lev_builder_config_file(b, "/etc/leviculum/config");
leviculum_t *node = lev_builder_build(b);
lev_builder_free(b);
lev_start(node); /* now a daemon: run the event loop until signalled */
Offer a shared instance, so other local programs and the Reticulum tools
(rnstatus, rnpath, rnprobe) attach to this one stack instead of each
opening the radio:
lev_builder_share_instance(b, "leviculum"); /* opens the IPC + RPC endpoint */
Or attach to a running daemon as a client, the way rncp/rnx do, instead of
bringing up interfaces of your own:
lev_builder_connect_shared_instance(b, "leviculum");
A NULL path or name returns LEV_ERR_INVALID_ARG. The daemon.c example is
a worked acceptance program for all three calls. The lncp.c file-copy tool
has both styles: its recv/send modes bring up their own interface, while its
recv-shared/send-shared modes attach to a running lnsd by instance name,
so several tools share one daemon’s radio.
Radio interfaces (LoRa and serial)
For off-grid mesh, add an RNode (LoRa) or a raw serial interface programmatically, no config file needed:
lev_builder_t *b = lev_builder_new();
/* RNode: device, frequency Hz, bandwidth Hz, spreading factor, coding rate,
* tx power dBm. */
lev_builder_add_rnode(b, "/dev/ttyUSB0", 867200000, 125000, 8, 5, 0);
/* Serial: device, speed, data bits, parity ("N"/"E"/"O"), stop bits. */
lev_builder_add_serial(b, "/dev/ttyACM0", 115200, 8, "N", 1);
The device is opened at lev_start, so a wrong path surfaces there, not at the
setter (which only rejects a NULL path with LEV_ERR_INVALID_ARG). A serial
port is raw KISS with no handshake; an RNode performs the RNode detect and
config handshake on start. For the optional RNode knobs (airtime limits, flow
control, buffer size), load a config file instead. The radio.c example brings
a node up over a serial interface.
Identities
An identity is a key pair. Generate one, persist it, and reload it next run. The on-disk format is the raw 64-byte private key, compatible with Python Reticulum.
lev_identity_t *id;
id = lev_identity_load_file("/var/lib/myapp/identity");
if (!id) { /* first run: make one */
id = lev_identity_generate();
lev_identity_save_file(id, "/var/lib/myapp/identity");
}
uint8_t hash[LEV_ADDR_LEN];
uintptr_t len = sizeof(hash);
lev_identity_hash(id, hash, sizeof(hash), &len); /* the 16-byte address */
A combined key is 64 bytes (LEV_IDENTITY_KEY_LEN): the X25519 encryption key
in bytes 0..32 and the Ed25519 signing key in bytes 32..64. Applications
rarely split it by hand, because lev_connect resolves the signing key for
you (see below). Use lev_builder_identity(b, id) to give a node a fixed
identity, and lev_identity_free(id) when done.
An identity also signs, verifies, encrypts, and decrypts directly, for crypto tooling and signed application data, interoperable with Python peers (Ed25519 for signatures, X25519+AES for encryption):
uint8_t sig[64];
uintptr_t n = sizeof(sig);
lev_identity_sign(id, msg, msg_len, sig, sizeof(sig), &n);
int ok = lev_identity_verify(id, msg, msg_len, sig, n); /* 1 valid, 0 not */
/* Encrypt to a peer's public-only identity; only its private key recovers it. */
uint8_t ct[512];
uintptr_t ctl = sizeof(ct);
lev_identity_encrypt(peer, msg, msg_len, ct, sizeof(ct), &ctl);
Sign, encrypt, and decrypt write read(2) style (a NULL buffer queries the
length); signing and decryption need the private key and return
LEV_ERR_CRYPTO on a public-only identity, while verify needs only the public
key.
Full programs: reticulum-ffi/examples/c/phase_a.c and crypto.c.
Announcing and discovering
To be reachable, a node registers an incoming destination and announces it.
Other nodes learn the destination (its address, identity, and a path) from the
announce, which arrives as LEV_EVENT_ANNOUNCE_RECEIVED.
Announcing side:
const char *aspects[] = { "inbox" };
lev_destination_t *dest = lev_destination_new(
id, LEV_DIRECTION_IN, LEV_DEST_SINGLE, "myapp", aspects, 1);
uint8_t dh[LEV_ADDR_LEN];
uintptr_t dhl = sizeof(dh);
lev_destination_hash(dest, dh, sizeof(dh), &dhl); /* read before registering */
lev_register_destination(node, dest); /* consumes dest */
lev_destination_free(dest); /* free the empty shell */
lev_announce(node, dh, NULL, 0, 2000); /* optional app_data, here none */
For forward secrecy, call lev_destination_enable_ratchets(dest, now_ms) on an
inbound destination before registering it (now_ms is the current time in
milliseconds); peers, including Python ones, then encrypt to a rotating ratchet
key. lev_destination_ratchet_public(node, dh, ...) reads the current key. See
reticulum-ffi/examples/c/ratchet.c.
For delivery proofs, call lev_destination_set_proof_strategy(dest, strategy)
before registering. LEV_PROOF_ALL auto-proves every received packet (Python’s
PROVE_ALL). LEV_PROOF_APP raises a LEV_EVENT_PACKET_PROOF_REQUESTED event
whose data is the 32-byte packet hash; the app decides and calls
lev_send_proof(node, dest_hash, packet_hash, timeout_ms). See
reticulum-ffi/examples/c/proof.c.
Receiving side, in the event loop:
case LEV_EVENT_ANNOUNCE_RECEIVED: {
uint8_t peer[LEV_ADDR_LEN];
uintptr_t n = sizeof(peer);
lev_event_dest_hash(ev, peer, sizeof(peer), &n); /* who announced */
/* optional payload via lev_event_data(ev, ...) */
break;
}
After processing the announce, the receiver has a path and the announcer’s
cached identity, so lev_has_path(node, peer) returns 1 and lev_connect will
work.
Full program: reticulum-ffi/examples/c/phase_b.c.
Links and exchanging data
A link is an encrypted session to a destination. lev_connect resolves the
peer’s signing key from the identity cached by an announce, so you pass only
the destination hash:
lev_link_t *link = NULL;
int rc = lev_connect(node, peer, 5000, &link);
if (rc == LEV_ERR_UNKNOWN_DEST) { /* no announce seen yet */ }
else if (rc == LEV_ERR_NO_PATH) { lev_request_path(node, peer, 3000); }
else if (rc == LEV_OK) { /* link is pending; wait for established */ }
The connecting node watches for LEV_EVENT_LINK_ESTABLISHED; the destination
node watches for LEV_EVENT_LINK_REQUEST and accepts it:
case LEV_EVENT_LINK_REQUEST: {
uint8_t lid[LEV_ADDR_LEN];
uintptr_t n = sizeof(lid);
lev_event_link_id(ev, lid, sizeof(lid), &n);
lev_link_t *accepted = NULL;
lev_accept_link(node, lid, 5000, &accepted);
/* keep `accepted` to send on this link */
break;
}
Send and receive link data. lev_link_send blocks up to its deadline,
retrying backpressure; lev_link_try_send returns LEV_ERR_AGAIN instead of
blocking. It sends over the link’s reliable channel (sequenced and
retransmitted, the same RawBytesMessage Python peers use), so the peer sees a
LEV_EVENT_LINK_MESSAGE, with a message type and a sequence number:
lev_link_send(link, (const uint8_t *)"hello", 5, 5000);
case LEV_EVENT_LINK_MESSAGE: {
uint8_t buf[512];
uintptr_t n = sizeof(buf);
uint16_t msgtype = 0, sequence = 0;
if (lev_event_data(ev, buf, sizeof(buf), &n) == LEV_OK) {
lev_event_msgtype(ev, &msgtype); /* 0 for raw bytes */
lev_event_sequence(ev, &sequence); /* per-channel send order */
/* `n` bytes received */
}
break;
}
A peer that sends a raw, unsequenced link packet instead of using the channel
(for example Python’s RNS.Packet(link, data).send()) arrives as the
lower-level LEV_EVENT_LINK_DATA, which carries only link_id and data.
Close with lev_close_link(link, 2000) and release with lev_link_free(link)
(which also closes an open link). A LEV_EVENT_LINK_CLOSED event reports a
link that drops for any reason.
Full program: reticulum-ffi/examples/c/phase_c.c.
Proving identity on a link
By default a link is anonymous. Either side can prove an identity to the peer;
the peer is notified with LEV_EVENT_LINK_IDENTIFIED and can read it back.
/* prover */
lev_link_identify(node, my_link_id, my_identity, 3000);
/* peer, in the event loop */
case LEV_EVENT_LINK_IDENTIFIED: {
lev_identity_t *who = lev_link_remote_identity(node, my_link_id);
if (who) {
uint8_t h[LEV_ADDR_LEN];
uintptr_t n = sizeof(h);
lev_identity_hash(who, h, sizeof(h), &n); /* the peer's address */
lev_identity_free(who);
}
break;
}
The 16-byte identity hash is also the payload of the
LEV_EVENT_LINK_IDENTIFIED event (lev_event_data).
Full program: reticulum-ffi/examples/c/phase_c.c.
Request and response
For a request/response service, the responder registers a handler for a path on its destination; the requester sends a request over a link. Request and response payloads are msgpack-encoded values.
Responder:
lev_register_request_handler(node, dh, "/echo",
LEV_REQUEST_POLICY_ALLOW_ALL, NULL, 0);
case LEV_EVENT_REQUEST_RECEIVED: {
uint8_t link_id[LEV_ADDR_LEN], req_id[LEV_ADDR_LEN], data[512];
uintptr_t a = sizeof(link_id), b = sizeof(req_id), c = sizeof(data);
lev_event_link_id(ev, link_id, sizeof(link_id), &a);
lev_event_request_id(ev, req_id, sizeof(req_id), &b);
lev_event_data(ev, data, sizeof(data), &c); /* the request body */
/* path is available via lev_event_path(ev, ...) */
lev_send_response(node, link_id, req_id, data, c, 3000); /* echo it */
break;
}
Requester (over an established link, whose id comes from lev_link_id):
uint8_t req[] = { 0xA4, 'p','i','n','g' }; /* msgpack "ping" */
uint8_t request_id[LEV_ADDR_LEN];
lev_send_request(node, link_id, "/echo", req, sizeof(req), 5000, request_id);
case LEV_EVENT_RESPONSE_RECEIVED: {
uint8_t rid[LEV_ADDR_LEN], body[512];
uintptr_t a = sizeof(rid), b = sizeof(body);
lev_event_request_id(ev, rid, sizeof(rid), &a); /* match request_id */
lev_event_data(ev, body, sizeof(body), &b);
break;
}
A request that gets no reply within its deadline surfaces as
LEV_EVENT_REQUEST_TIMEOUT. To restrict callers, use
LEV_REQUEST_POLICY_ALLOW_LIST with an array of n_ids 16-byte identity
hashes.
Full program: reticulum-ffi/examples/c/phase_d.c.
Datagrams
A datagram is a single, unreliable packet to a destination. A path must
already be known. Delivery is best-effort: a LEV_EVENT_PACKET_RECEIVED on the
other side, and a delivery confirmation only if the destination returns a
proof.
uint8_t packet_hash[LEV_ADDR_LEN];
int rc = lev_send_datagram(node, dest_hash, (const uint8_t *)"hi", 2,
packet_hash, 3000);
if (rc == LEV_ERR_NO_PATH) { lev_request_path(node, dest_hash, 3000); }
/* receiver */
case LEV_EVENT_PACKET_RECEIVED: {
uint8_t buf[256];
uintptr_t n = sizeof(buf);
lev_event_data(ev, buf, sizeof(buf), &n);
break;
}
Full program: reticulum-ffi/examples/c/phase_d.c.
Resource transfer
A resource carries bulk data (a file) over a link, in segments, with optional compression and msgpack metadata. The receiver chooses a strategy: accept all, reject all, or be asked per transfer.
Receiver sets a strategy on the link, then accepts when advertised:
lev_set_resource_strategy(node, link_id, LEV_RESOURCE_ACCEPT_APP);
case LEV_EVENT_RESOURCE_ADVERTISED:
lev_accept_resource(node, link_id, 3000); /* or lev_reject_resource */
break;
case LEV_EVENT_RESOURCE_COMPLETED: {
uint8_t buf[65536];
uintptr_t n = sizeof(buf);
lev_event_data(ev, buf, sizeof(buf), &n); /* the assembled data */
/* metadata via lev_event_metadata(ev, ...) if present */
break;
}
Sender initiates the transfer and tracks progress:
uint8_t resource_hash[LEV_RESOURCE_HASH_LEN];
lev_send_resource(node, link_id, file_data, file_len,
NULL, 0, /* optional msgpack metadata */
1, /* auto-compress */
resource_hash, 5000);
case LEV_EVENT_RESOURCE_PROGRESS: {
double frac;
lev_event_progress(ev, &frac); /* 0.0 .. 1.0 */
break;
}
LEV_EVENT_RESOURCE_COMPLETED carries the data only on the receiver;
LEV_EVENT_RESOURCE_FAILED reports a transfer that did not finish.
lev_send_resource returns once the transfer is initiated: the receiver then
pulls the parts part by part. A sending program must keep its node alive and
running the event loop until the transfer is done, the receiver must keep the
link it accepted open (freeing a link closes it), and the receiver applies its
resource strategy on the link before the resource arrives. Exiting the sender
right after the call returns aborts an in-flight transfer.
Full programs: reticulum-ffi/examples/c/phase_e.c, and
reticulum-ffi/examples/c/lncp.c, a complete two-process file-copy tool
(lncp send / lncp recv) that exercises the whole stack end to end.
Errors and logging
Every fallible call returns int. Pair the code with the thread-local detail:
int rc = lev_connect(node, peer, 5000, &link);
if (rc != LEV_OK) {
fprintf(stderr, "connect: %s (%s)\n", lev_strerror(rc), lev_last_error());
}
LEV_ERR_AGAIN (from lev_link_try_send) and LEV_ERR_TIMEOUT are normal,
retryable conditions, not hard failures. Logging from the stack itself is off
by default; turn it on and route it to your own sink:
static void log_sink(int level, const char *msg, void *user) {
(void)user;
fprintf(stderr, "[lev %d] %s\n", level, msg);
}
lev_init();
lev_log_set_callback(log_sink, NULL);
lev_log_set_level(LEV_LOG_INFO);
The callback may run on an internal thread and must not call back into any
lev_* function. For hex display of an address, use lev_hex_encode and
lev_hex_decode.
Diagnostics
For an rnstatus-style view, lev_transport_stats reads the transport
counters and the path-table size:
uint64_t sent, received, dropped, paths;
lev_transport_stats(node, &sent, &received, NULL, NULL, &dropped, &paths);
Any out-pointer may be NULL to skip it.
For an rnpath-style listing, take a frozen snapshot of the path table, read
its entries by index, and free it:
lev_path_table_t *table = lev_path_table_snapshot(node);
for (int i = 0; i < lev_path_table_count(table); i++) {
uint8_t dest[LEV_ADDR_LEN];
uint8_t hops;
lev_path_table_entry(table, i, dest, &hops, NULL, NULL, NULL, NULL);
/* `dest` reachable in `hops` hops */
}
lev_path_table_free(table);
The snapshot is a point-in-time copy, so reads never race a changing table.
Interface stats work the same way (lev_interface_stats_snapshot /
_count / _name / _entry / _free), giving each interface’s name, online
status, and byte counters for an rnstatus-style interface listing. See
reticulum-ffi/examples/c/stats.c.
Putting it together
A typical application wires these into one loop: it loads or generates an
identity, builds and starts a node with an interface, registers and announces a
destination, then runs the event loop, reacting to announces by connecting,
to link requests by accepting, and to data, request, and resource events by
serving the application. The phase_b.c through phase_e.c programs are
complete two-node demonstrations of exactly these flows, runnable via
cargo test-ffi.
C API: Reference
Every public function and constant of leviculum.h, grouped by area. The
header reticulum-ffi/leviculum.h is generated from the Rust source and is the
canonical statement of the exact prototypes; this reference is kept in sync
with it and adds semantics. For the model behind these signatures (handles,
the read(2) buffer convention, out-parameters, the event fd, threading), read
the Overview first.
Conventions used below:
- Functions return
int:LEV_OK(0) on success, a negativeLEV_ERR_*on failure; constructors returnNULLon failure. After any failure,lev_last_error()holds a detail string. - A
(uint8_t *buf, uintptr_t cap, uintptr_t *out_len)triple is read(2) style:buf == NULLor too-smallcapreturnsLEV_ERR_BUFFER_TOO_SMALLwith*out_lenset to the required size. timeout_msis a deadline in milliseconds; negative means wait forever; on expiry the call returnsLEV_ERR_TIMEOUT.
Opaque types
| Type | Created by | Freed by | Thread-safety |
|---|---|---|---|
leviculum_t | lev_builder_build | lev_free | thread-safe; events single-consumer |
lev_builder_t | lev_builder_new | lev_builder_free | one thread |
lev_identity_t | lev_identity_generate, lev_identity_from_private_key, lev_identity_from_public_key, lev_identity_load_file, lev_link_remote_identity | lev_identity_free | one thread |
lev_destination_t | lev_destination_new | lev_destination_free | one thread |
lev_link_t | lev_connect, lev_connect_with_key, lev_accept_link | lev_link_free | sends thread-safe; do not close/free concurrently with other calls on the same link |
lev_event_t | lev_next_event, lev_wait_event | lev_event_free | one thread |
lev_path_table_t | lev_path_table_snapshot | lev_path_table_free | one thread |
lev_interface_stats_t | lev_interface_stats_snapshot | lev_interface_stats_free | one thread |
Initialisation and logging
int lev_init(void);
int lev_log_set_level(int level);
int lev_log_set_callback(lev_log_callback cb, void *user);
typedef void (*lev_log_callback)(int level, const char *message, void *user);
lev_initruns one-time process setup (logging subscriber, panic hook) once, through an internalOnce. Idempotent and thread-safe. Optional: other entry points run it lazily.lev_log_set_levelsets the global verbosity to one of theLEV_LOG_*constants. ReturnsLEV_ERR_INVALID_ARGfor an out-of-range level.lev_log_set_callbackroutes log records tocb(withuserpassed back unchanged), or restores the stderr default whencbisNULL. The callback may run on any internal worker thread, receives a NUL-terminated message valid only for the call, and must not call back into anylev_*function.
| Constant | Value | Meaning |
|---|---|---|
LEV_LOG_OFF | 0 | no logging (default) |
LEV_LOG_ERROR | 1 | errors only |
LEV_LOG_WARN | 2 | warnings and above |
LEV_LOG_INFO | 3 | info and above |
LEV_LOG_DEBUG | 4 | debug and above |
LEV_LOG_TRACE | 5 | everything |
Versioning
const char *lev_version_string(void);
uint32_t lev_version_number(void);
lev_version_stringreturns the version (for example"0.7.0") as a static, never-freed string.lev_version_numberpacks it as(major << 16) | (minor << 8) | patch, a host-byte-order integer for in-process comparison only.
Errors
const char *lev_strerror(int code);
const char *lev_last_error(void);
lev_strerrorreturns a static message for aLEV_ERR_*code; safe any time, never freed.lev_last_errorreturns the thread-local detail string for the most recent failing call on the calling thread, orNULLif there is none. Owned by the library, valid until the next failing call on the same thread, never freed.
Error codes
| Constant | Value | Meaning |
|---|---|---|
LEV_OK | 0 | success |
LEV_ERR_NULL_PTR | -1 | a required pointer argument was NULL |
LEV_ERR_INVALID_ARG | -2 | malformed argument (bad length, unparseable string) |
LEV_ERR_BUFFER_TOO_SMALL | -3 | caller buffer too small; *out_len holds the needed size |
LEV_ERR_NOT_RUNNING | -4 | the node event loop is not running |
LEV_ERR_IO | -5 | an I/O or storage error |
LEV_ERR_CONFIG | -6 | a configuration error |
LEV_ERR_CRYPTO | -7 | a cryptographic operation failed |
LEV_ERR_NO_PATH | -8 | no path to the destination is known |
LEV_ERR_LINK | -9 | a link operation failed (closed, inactive, handshake) |
LEV_ERR_SEND | -10 | a send failed (no route, payload too large) |
LEV_ERR_RESOURCE | -11 | a resource transfer operation failed |
LEV_ERR_REQUEST | -12 | a request or response operation failed |
LEV_ERR_TIMEOUT | -13 | the operation timed out |
LEV_ERR_AGAIN | -14 | non-fatal backpressure; retry later |
LEV_ERR_UNKNOWN_DEST | -15 | no cached identity for the destination |
LEV_ERR_PANIC | -127 | a panic was caught at the FFI boundary |
Identity
struct lev_identity_t *lev_identity_generate(void);
struct lev_identity_t *lev_identity_from_private_key(const uint8_t *key, uintptr_t len);
struct lev_identity_t *lev_identity_from_public_key(const uint8_t *key, uintptr_t len);
struct lev_identity_t *lev_identity_load_file(const char *path);
int lev_identity_save_file(const struct lev_identity_t *id, const char *path);
void lev_identity_free(struct lev_identity_t *id);
int lev_identity_hash(const struct lev_identity_t *id, uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
int lev_identity_public_key(const struct lev_identity_t *id, uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
int lev_identity_private_key(const struct lev_identity_t *id, uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
int lev_identity_has_private_keys(const struct lev_identity_t *id);
int lev_identity_sign(const struct lev_identity_t *id, const uint8_t *msg, uintptr_t msg_len, uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
int lev_identity_verify(const struct lev_identity_t *id, const uint8_t *msg, uintptr_t msg_len, const uint8_t *sig, uintptr_t sig_len);
int lev_identity_encrypt(const struct lev_identity_t *id, const uint8_t *plaintext, uintptr_t plaintext_len, uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
int lev_identity_decrypt(const struct lev_identity_t *id, const uint8_t *ciphertext, uintptr_t ciphertext_len, uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
lev_identity_generatemakes a new random full identity;NULLon failure.lev_identity_from_private_key/lev_identity_from_public_keybuild an identity from a 64-byte combined key (lenmust equalLEV_IDENTITY_KEY_LEN); the public-key variant yields a public-only identity.NULLon failure.lev_identity_load_filereads the raw 64-byte private key file (the Python-Reticulum format);NULLif missing, wrong size, or invalid.lev_identity_save_filewrites the private key topathatomically;LEV_ERR_CRYPTOif the identity is public-only.lev_identity_hashwrites the 16-byte identity hash;_public_keyand_private_keywrite the 64-byte combined keys (_private_keyreturnsLEV_ERR_CRYPTOfor a public-only identity). All read(2) style.lev_identity_has_private_keysreturns 1 for a full identity, 0 otherwise (and 0 onNULL).lev_identity_signwrites the 64-byte Ed25519 signature ofmsgread(2) style;LEV_ERR_CRYPTOif the identity is public-only.lev_identity_verifyreturns 1 if the signature is valid, 0 if not (including a wrong-length signature), and a negativeLEV_ERR_*on a NULL argument; it needs only the public key.lev_identity_encryptencryptsplaintextto the identity’s public key (the Reticulum X25519+AES scheme) and writes the ciphertext read(2) style; encryption is randomised, so a length query and the real call differ in bytes but not length.lev_identity_decryptreverses it with the private key and returnsLEV_ERR_CRYPTOfor a public-only identity or a ciphertext that fails to authenticate.
Every returned lev_identity_t is owned by the caller and freed with
lev_identity_free.
| Constant | Value | Meaning |
|---|---|---|
LEV_ADDR_LEN | 16 | destination, link, and identity hash length |
LEV_IDENTITY_KEY_LEN | 64 | combined key length (public or private) |
LEV_X25519_KEY_LEN | 32 | encryption half, bytes 0..32 |
LEV_SIGNING_KEY_LEN | 32 | Ed25519 signing half, bytes 32..64 |
Node lifecycle and builder
struct lev_builder_t *lev_builder_new(void);
void lev_builder_free(struct lev_builder_t *b);
int lev_builder_identity(struct lev_builder_t *b, const struct lev_identity_t *id);
int lev_builder_storage_path(struct lev_builder_t *b, const char *path);
int lev_builder_add_tcp_client(struct lev_builder_t *b, const char *addr);
int lev_builder_add_tcp_server(struct lev_builder_t *b, const char *addr);
int lev_builder_add_udp(struct lev_builder_t *b, const char *listen_addr, const char *forward_addr);
int lev_builder_add_auto_interface(struct lev_builder_t *b);
int lev_builder_add_rnode(struct lev_builder_t *b, const char *port, uint64_t frequency, uint32_t bandwidth, uint8_t spreading_factor, uint8_t coding_rate, int8_t tx_power);
int lev_builder_add_serial(struct lev_builder_t *b, const char *port, uint32_t speed, uint8_t databits, const char *parity, uint8_t stopbits);
int lev_builder_enable_transport(struct lev_builder_t *b, int enabled);
int lev_builder_event_capacity(struct lev_builder_t *b, uintptr_t control_cap, uintptr_t data_cap);
int lev_builder_link_keepalive(struct lev_builder_t *b, uint64_t secs);
int lev_builder_config_file(struct lev_builder_t *b, const char *path);
int lev_builder_share_instance(struct lev_builder_t *b, const char *name);
int lev_builder_connect_shared_instance(struct lev_builder_t *b, const char *name);
struct leviculum_t *lev_builder_build(struct lev_builder_t *b);
int lev_start(struct leviculum_t *node);
int lev_stop(struct leviculum_t *node);
int lev_is_running(const struct leviculum_t *node);
int lev_identity_hash_self(const struct leviculum_t *node, uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
void lev_free(struct leviculum_t *node);
lev_builder_newallocates a builder;lev_builder_freereleases it (lev_builder_free(NULL)is a no-op).- The setters configure the node:
_identitypins a (cloned) identity,_storage_pathsets the state directory, the_add_*calls add interfaces (TCP addresses arehost:port),_enable_transporttoggles relay mode, and_event_capacitysets the event-queue sizes (control and data planes; a 0 keeps the current default). Each setter returnsLEV_ERR_INVALID_ARGif the builder was already consumed. lev_builder_link_keepaliveoverrides the link keepalive interval, in seconds, for every link the node creates; the stale-link timeout scales with it (a link goes stale after twice the keepalive). The value is clamped to the protocol minimum. The default (no call) derives the interval from the link RTT. Useful for slow links, and for makingLEV_EVENT_LINK_STALEobservable quickly. The same knob is thekeepalive_intervalkey in a config file.lev_builder_add_rnodeadds a LoRa interface over an RNode:portis the serial device, then the required radio settings (frequencyandbandwidthin Hz,spreading_factor,coding_ratedenominator,tx_powerin dBm).lev_builder_add_serialadds a raw KISS serial interface:port,speed,databits,parity("N","E", or"O"),stopbits. Both returnLEV_ERR_INVALID_ARGon a NULL device path (or NULL parity). For the optional RNode tuning (airtime limits, flow control, buffer size) use a config file. The device is opened atlev_start, not when the setter runs.lev_builder_config_fileloads an RNS-style INI config (the same formatrnsd/lnsdread) frompath; its[reticulum]and[interfaces]sections add to whatever the builder set programmatically. Loading a config brings up every interface type it names, including RNode and Serial, so a C node reaches LoRa through a config file.lev_builder_share_instancemakes the node offer a shared instance undername: it opens a local IPC endpoint and thernstatus/rnpath/rnprobeRPC server, so other local programs (and tools) attach to this one stack.lev_builder_connect_shared_instancemakes the node a client of a shared instance namednameinstead of bringing up its own interfaces, the wayrncp/rnxattach to a running daemon. ANULLpath or name returnsLEV_ERR_INVALID_ARG.lev_builder_buildproduces aleviculum_tand empties the builder; you still calllev_builder_freeon the empty handle.NULLon failure.lev_startspawns the event loop and brings up interfaces;lev_stoppersists state and tears it down; a stopped node can be started again.lev_starton a running node returnsLEV_ERR_CONFIG.lev_is_runningreturns 1 while the loop runs (0 onNULL).lev_identity_hash_selfwrites the node’s own 16-byte identity hash.lev_freestops a running node and releases it (lev_free(NULL)is a no-op). Call it, and the other blocking calls, from a plain OS thread.
The event-side functions on a node (lev_event_fd, lev_next_event,
lev_wait_event) are documented under Events.
Destinations and announce
struct lev_destination_t *lev_destination_new(const struct lev_identity_t *identity,
int direction, int dest_type,
const char *app_name,
const char *const *aspects, uintptr_t n_aspects);
void lev_destination_free(struct lev_destination_t *dest);
int lev_destination_hash(const struct lev_destination_t *dest, uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
int lev_destination_enable_ratchets(struct lev_destination_t *dest, uint64_t now_ms);
int lev_destination_ratchet_public(const struct leviculum_t *node, const uint8_t *dest_hash, uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
int lev_destination_set_proof_strategy(struct lev_destination_t *dest, int strategy);
int lev_send_proof(const struct leviculum_t *node, const uint8_t *dest_hash, const uint8_t *packet_hash, int timeout_ms);
int lev_register_destination(const struct leviculum_t *node, struct lev_destination_t *dest);
int lev_announce(const struct leviculum_t *node, const uint8_t *dest_hash,
const uint8_t *app_data, uintptr_t app_data_len, int timeout_ms);
int lev_send_datagram(const struct leviculum_t *node, const uint8_t *dest_hash,
const uint8_t *data, uintptr_t data_len, uint8_t *out_hash, int timeout_ms);
lev_destination_newbuilds a destination from an identity (may beNULL; required for some types, forbidden forLEV_DEST_PLAIN), a direction, a type, anapp_name, and an array ofn_aspectsNUL-terminated aspect strings.NULLon failure.lev_destination_hashwrites the 16-byte hash; read it before registering. ReturnsLEV_ERR_INVALID_ARGonce the destination has been consumed.lev_destination_enable_ratchetsturns on forward secrecy for an inbound destination before it is registered;now_msis the current time in milliseconds, seeding ratchet rotation.LEV_ERR_INVALID_ARGfor an outbound destination or one already registered.lev_destination_ratchet_publicreads the current 32-byte ratchet public key of a registered destination (read(2) style), orLEV_ERR_INVALID_ARGif it has no ratchets. Ratcheted destinations interoperate with Python peers.lev_destination_set_proof_strategysets, before registration, how a destination proves delivery of received packets:LEV_PROOF_NONE(default, never),LEV_PROOF_APP(emitLEV_EVENT_PACKET_PROOF_REQUESTEDso the app decides, then callslev_send_proof), orLEV_PROOF_ALL(auto-prove every packet, Python’s PROVE_ALL).lev_send_proofsends a delivery proof for thepacket_hashfrom a proof-requested event;LEV_ERR_SENDif no return path exists.lev_register_destinationregisters the destination on the node so it can be announced and accept links and packets. It consumes the destination (the handle is emptied; still free it).LEV_ERR_INVALID_ARGif already registered.lev_announcebroadcasts a registered destination (by 16-byte hash) on all interfaces, with optionalapp_data.lev_send_datagramsends one unreliable packet to a destination and writes the 16-byte packet hash intoout_hash. A path must be known (LEV_ERR_NO_PATHotherwise).
| Constant | Value | Meaning |
|---|---|---|
LEV_DIRECTION_IN | 0 | incoming: receives announces, links, packets |
LEV_DIRECTION_OUT | 1 | outgoing: a source address for sending |
LEV_DEST_SINGLE | 0 | point-to-point, ephemeral encryption |
LEV_DEST_GROUP | 1 | shared-key broadcast |
LEV_DEST_PLAIN | 2 | unencrypted |
Paths, connect, and links
int lev_has_path(const struct leviculum_t *node, const uint8_t *dest_hash);
int lev_hops_to(const struct leviculum_t *node, const uint8_t *dest_hash, uint8_t *out);
int lev_request_path(const struct leviculum_t *node, const uint8_t *dest_hash, int timeout_ms);
int lev_connect(const struct leviculum_t *node, const uint8_t *dest_hash,
int timeout_ms, struct lev_link_t **out);
int lev_connect_with_key(const struct leviculum_t *node, const uint8_t *dest_hash,
const uint8_t *signing_key, int timeout_ms, struct lev_link_t **out);
int lev_accept_link(const struct leviculum_t *node, const uint8_t *link_id,
int timeout_ms, struct lev_link_t **out);
int lev_link_send(const struct lev_link_t *link, const uint8_t *data, uintptr_t len, int timeout_ms);
int lev_link_try_send(const struct lev_link_t *link, const uint8_t *data, uintptr_t len);
int lev_link_id(const struct lev_link_t *link, uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
int lev_link_is_closed(const struct lev_link_t *link);
int lev_link_identify(const struct leviculum_t *node, const uint8_t *link_id,
const struct lev_identity_t *identity, int timeout_ms);
struct lev_identity_t *lev_link_remote_identity(const struct leviculum_t *node, const uint8_t *link_id);
int lev_close_link(struct lev_link_t *link, int timeout_ms);
void lev_link_free(struct lev_link_t *link);
lev_has_pathreturns 1 if a path to the destination is known, else 0 (negative on a NULL argument).lev_hops_towrites the hop count into*outor returnsLEV_ERR_NO_PATH.lev_request_pathasks the network for a path; the result arrives as an event andlev_has_paththen returns 1.lev_connectopens a link by destination hash, resolving the peer’s signing key from the identity cached by an announce;*outreceives the link. ReturnsLEV_ERR_UNKNOWN_DESTif no identity is cached andLEV_ERR_NO_PATHif no path is known (it does not auto-request one).lev_connect_with_keyis the same with an explicit 32-byte Ed25519 signing key, for out-of-band peers.lev_accept_linkaccepts an incoming link request (16-byte link id from aLEV_EVENT_LINK_REQUESTevent);*outreceives the link.lev_link_sendsends data, retrying backpressure up to the deadline (thenLEV_ERR_TIMEOUT);lev_link_try_sendnever blocks and returnsLEV_ERR_AGAINunder backpressure. Inbound data arrives asLEV_EVENT_LINK_DATA.lev_link_idwrites the 16-byte link id.lev_link_is_closedreturns 1 if closed (0 onNULL).lev_link_identifyproves an identity to the peer (who seesLEV_EVENT_LINK_IDENTIFIED);lev_link_remote_identityreturns the peer’s identity as a new handle the caller frees, orNULLif the peer has not identified.lev_close_linkcloses gracefully (idempotent);lev_link_freereleases the handle, closing an open link first.
Request and response
int lev_register_request_handler(const struct leviculum_t *node, const uint8_t *dest_hash,
const char *path, int policy,
const uint8_t *allow_identity_hashes, uintptr_t n_ids);
int lev_send_request(const struct leviculum_t *node, const uint8_t *link_id, const char *path,
const uint8_t *data, uintptr_t data_len,
int response_timeout_ms, uint8_t *out_request_id);
int lev_send_response(const struct leviculum_t *node, const uint8_t *link_id,
const uint8_t *request_id, const uint8_t *data, uintptr_t data_len, int timeout_ms);
lev_register_request_handlerregisters a handler forpathon a local destination. ForLEV_REQUEST_POLICY_ALLOW_LIST,allow_identity_hashesisn_ids * 16bytes of identity hashes; otherwise passNULL, 0. Registering overwrites a previous handler for the same destination and path; there is no unregister.lev_send_requestsends a request on an established link topathand writes the 16-byte request id intoout_request_id.datais the msgpack-encoded payload (NULL, 0for none);response_timeout_msis the request-response deadline. The response (LEV_EVENT_RESPONSE_RECEIVED) or a timeout (LEV_EVENT_REQUEST_TIMEOUT) arrives as an event.lev_send_responsereplies to a received request (link id and request id from theLEV_EVENT_REQUEST_RECEIVEDevent);datamust be one valid msgpack-encoded value.
| Constant | Value | Meaning |
|---|---|---|
LEV_REQUEST_POLICY_ALLOW_NONE | 0 | drop all requests |
LEV_REQUEST_POLICY_ALLOW_ALL | 1 | allow any identity |
LEV_REQUEST_POLICY_ALLOW_LIST | 2 | allow only listed identity hashes |
Resource transfer
int lev_send_resource(const struct leviculum_t *node, const uint8_t *link_id,
const uint8_t *data, uintptr_t data_len,
const uint8_t *metadata, uintptr_t metadata_len,
int auto_compress, uint8_t *out_hash, int timeout_ms);
int lev_set_resource_strategy(const struct leviculum_t *node, const uint8_t *link_id, int strategy);
int lev_accept_resource(const struct leviculum_t *node, const uint8_t *link_id, int timeout_ms);
int lev_reject_resource(const struct leviculum_t *node, const uint8_t *link_id, int timeout_ms);
lev_send_resourcesends bulk data over a link and writes the 32-byte resource hash intoout_hash.metadata, if present, must be msgpack-encoded;auto_compressis 0 or 1. The call blocks only for the initial dispatch; progress and completion arrive as events.lev_set_resource_strategysets how incoming resources on a link are handled (one of theLEV_RESOURCE_*constants).lev_accept_resource/lev_reject_resourceanswer aLEV_EVENT_RESOURCE_ADVERTISEDevent under the AcceptApp strategy.
| Constant | Value | Meaning |
|---|---|---|
LEV_RESOURCE_ACCEPT_NONE | 0 | reject all incoming resources |
LEV_RESOURCE_ACCEPT_ALL | 1 | accept all automatically |
LEV_RESOURCE_ACCEPT_APP | 2 | advertise to the app to accept or reject |
LEV_RESOURCE_HASH_LEN | 32 | resource hash length |
Events
int lev_event_fd(const struct leviculum_t *node);
int lev_next_event(struct leviculum_t *node, struct lev_event_t **out);
int lev_wait_event(struct leviculum_t *node, struct lev_event_t **out, int timeout_ms);
void lev_event_free(struct lev_event_t *ev);
int lev_event_type(const struct lev_event_t *ev);
int lev_event_link_id(const struct lev_event_t *ev, uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
int lev_event_dest_hash(const struct lev_event_t *ev, uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
int lev_event_request_id(const struct lev_event_t *ev, uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
int lev_event_resource_hash(const struct lev_event_t *ev, uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
int lev_event_path(const struct lev_event_t *ev, uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
int lev_event_data(const struct lev_event_t *ev, uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
int lev_event_metadata(const struct lev_event_t *ev, uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
int lev_event_progress(const struct lev_event_t *ev, double *out);
int lev_event_dropped_count(const struct lev_event_t *ev, uint64_t *out);
int lev_event_msgtype(const struct lev_event_t *ev, uint16_t *out);
int lev_event_sequence(const struct lev_event_t *ev, uint16_t *out);
int lev_event_is_sender(const struct lev_event_t *ev);
lev_event_fdreturns the readable fd to add to apoll/epoll/selectloop. The library owns it and closes it inlev_free; never close it.lev_next_eventdequeues without blocking: on success*outis an event handle, orNULLwhen the queue is empty.lev_wait_eventblocks up totimeout_ms(negative forever);*outisNULLif the timeout elapses. It wakes promptly when an event arrives (the event fd is the real wake source) and otherwise rechecks at most every 250 ms, so an infinite wait still returns soon after an event lands. Both are single-consumer for a node. Free each event withlev_event_free.lev_event_typereturns the event’sLEV_EVENT_*type (0 onNULL).- The accessors read a field of the event, read(2) style for the byte fields:
_link_id,_dest_hash,_request_id(16 bytes each),_resource_hash(32 bytes),_path(UTF-8 bytes, not NUL-terminated),_data(the primary payload, possibly empty),_metadata(msgpack bytes)._progresswrites adoublein0.0..1.0for resource-progress events;_dropped_countwrites the count of aLEV_EVENT_CONTROL_OVERFLOWevent;_msgtypeand_sequencewrite the message type and sequence of aLEV_EVENT_LINK_MESSAGEevent. An accessor that does not apply to the event type returnsLEV_ERR_INVALID_ARG. lev_event_is_senderreturns 1 on the sender side of a resource event (_PROGRESS/_COMPLETED/_FAILED), 0 on the receiver side (or for other events). A sender’sLEV_EVENT_RESOURCE_COMPLETED(empty data) signals that an outgoing transfer finished; a receiver’s carries the data. A node that both sends and receives resources uses this to tell the two apart.
Event types
| Constant | Value | Fields available |
|---|---|---|
LEV_EVENT_OTHER | 0 | catch-all for events without a typed projection |
LEV_EVENT_ANNOUNCE_RECEIVED | 1 | dest_hash, data (app_data) |
LEV_EVENT_PATH_FOUND | 2 | dest_hash |
LEV_EVENT_LINK_REQUEST | 3 | link_id, dest_hash |
LEV_EVENT_LINK_ESTABLISHED | 4 | link_id |
LEV_EVENT_LINK_CLOSED | 5 | link_id |
LEV_EVENT_LINK_DATA | 6 | link_id, data |
LEV_EVENT_PACKET_RECEIVED | 7 | dest_hash, data |
LEV_EVENT_CONTROL_OVERFLOW | 8 | dropped_count |
LEV_EVENT_REQUEST_RECEIVED | 9 | link_id, request_id, path, data |
LEV_EVENT_RESPONSE_RECEIVED | 10 | link_id, request_id, data |
LEV_EVENT_REQUEST_TIMEOUT | 11 | link_id, request_id |
LEV_EVENT_RESOURCE_ADVERTISED | 12 | link_id, resource_hash |
LEV_EVENT_RESOURCE_STARTED | 13 | link_id, resource_hash |
LEV_EVENT_RESOURCE_PROGRESS | 14 | link_id, resource_hash, progress |
LEV_EVENT_RESOURCE_COMPLETED | 15 | link_id, resource_hash, data, metadata |
LEV_EVENT_RESOURCE_FAILED | 16 | link_id, resource_hash |
LEV_EVENT_LINK_IDENTIFIED | 17 | link_id, data (16-byte identity hash) |
LEV_EVENT_LINK_MESSAGE | 18 | link_id, data, msgtype, sequence (reliable channel) |
LEV_EVENT_PACKET_PROOF_REQUESTED | 19 | dest_hash, data (32-byte packet hash) |
LEV_EVENT_LINK_PROOF_REQUESTED | 20 | link_id, data (32-byte packet hash) |
LEV_EVENT_LINK_DELIVERY_CONFIRMED | 21 | link_id, data (32-byte packet hash) |
LEV_EVENT_LINK_STALE | 22 | link_id (link inactive past keepalive) |
LEV_EVENT_LINK_RECOVERED | 23 | link_id (stale link resumed) |
LEV_EVENT_PATH_LOST | 24 | dest_hash (path expired) |
LEV_EVENT_PACKET_DELIVERY_CONFIRMED | 25 | data (16-byte packet hash) |
LEV_EVENT_DELIVERY_FAILED | 26 | data (16-byte packet hash) |
Diagnostics
int lev_transport_stats(const struct leviculum_t *node,
uint64_t *out_packets_sent, uint64_t *out_packets_received,
uint64_t *out_packets_forwarded, uint64_t *out_announces_processed,
uint64_t *out_packets_dropped, uint64_t *out_path_count);
struct lev_path_table_t *lev_path_table_snapshot(const struct leviculum_t *node);
int lev_path_table_count(const struct lev_path_table_t *table);
int lev_path_table_entry(const struct lev_path_table_t *table, uintptr_t index,
uint8_t *dest_hash, uint8_t *hops, uint8_t *next_hop,
int *has_next_hop, uint64_t *interface_index, uint64_t *expires_ms);
void lev_path_table_free(struct lev_path_table_t *table);
struct lev_interface_stats_t *lev_interface_stats_snapshot(const struct leviculum_t *node);
int lev_interface_stats_count(const struct lev_interface_stats_t *table);
int lev_interface_stats_name(const struct lev_interface_stats_t *table, uintptr_t index,
uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
int lev_interface_stats_entry(const struct lev_interface_stats_t *table, uintptr_t index,
int *online, int *is_local_client,
uint64_t *rx_bytes, uint64_t *tx_bytes);
void lev_interface_stats_free(struct lev_interface_stats_t *table);
lev_transport_statsreads the node’s transport counters and the current path-table size into the out-parameters, the basis for anrnstatus-style view. Any out-pointer may beNULLto skip that counter; aNULLnode returnsLEV_ERR_NULL_PTR. The values are a point-in-time snapshot.lev_path_table_snapshotreturns an owned, frozen copy of the path table for anrnpath-style view, orNULLon aNULLnode; free it withlev_path_table_free(NULLis a no-op). Because it is frozen, reads never race a changing table.lev_path_table_countgives the number of entries.lev_path_table_entryreads one entry by index into the out-parameters:dest_hashandnext_hop(each at leastLEV_ADDR_LENbytes when non-NULL),hops,has_next_hop(1 for a relayed path, 0 for a direct one),interface_index, andexpires_ms. Any out-pointer may beNULL;LEV_ERR_INVALID_ARGifindexis out of range.lev_interface_stats_snapshotreturns an owned, frozen copy of the interface list for anrnstatus-style interface view, freed withlev_interface_stats_free.lev_interface_stats_countgives the number of interfaces.lev_interface_stats_namereads the interface name read(2) style (variable length), andlev_interface_stats_entryreads the scalar fields (online,is_local_client,rx_bytes,tx_bytes) into out-parameters. Both returnLEV_ERR_INVALID_ARGfor an out-of-range index.
Helpers
int lev_hex_encode(const uint8_t *data, uintptr_t len, uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
int lev_hex_decode(const uint8_t *hex, uintptr_t hex_len, uint8_t *buf, uintptr_t cap, uintptr_t *out_len);
lev_hex_encodewrites2 * lenlowercase hex bytes (not NUL-terminated), read(2) style.lev_hex_decodewriteshex_len / 2bytes;LEV_ERR_INVALID_ARGon an odd length or a non-hex digit.
RNode Interface Protocol Research
Research based on Python RNS v1.1.3, source files:
RNS/Interfaces/RNodeInterface.py(1558 lines)RNS/Interfaces/RNodeMultiInterface.py(1149 lines)RNS/Interfaces/Interface.py(302 lines, base class)RNS/Interfaces/KISSInterface.py(standard KISS, for comparison)
1. Serial Protocol
1.1 Framing
The RNode serial protocol uses KISS framing, not HDLC. This is a critical distinction from the TCP/Serial framing used elsewhere in Reticulum.
| Constant | Value | Purpose |
|---|---|---|
FEND | 0xC0 | Frame delimiter (start and end) |
FESC | 0xDB | Escape byte |
TFEND | 0xDC | Escaped FEND (after FESC) |
TFESC | 0xDD | Escaped FESC (after FESC) |
Frame format:
[FEND 0xC0] [CMD byte] [escaped payload...] [FEND 0xC0]
Escaping (KISS standard):
When the payload contains 0xC0 (FEND) or 0xDB (FESC), they are replaced:
0xDB->0xDB 0xDD(FESC TFESC) – escape is applied FIRST0xC0->0xDB 0xDC(FESC TFEND)
Note the escape ordering: FESC bytes are escaped first, then FEND bytes.
This matches Python’s data.replace(bytes([0xdb]), bytes([0xdb, 0xdd])).replace(bytes([0xc0]), bytes([0xdb, 0xdc])).
Comparison with HDLC framing (used for TCP):
| Property | KISS (RNode) | HDLC (TCP) |
|---|---|---|
| Delimiter | 0xC0 | 0x7E |
| Escape byte | 0xDB | 0x7D |
| Escape method | Substitution (0xDC/0xDD) | XOR with 0x20 |
| CRC | None | None (Reticulum simplified HDLC) |
| First byte after delimiter | Command byte | Payload starts immediately |
Key difference: KISS frames carry a command byte after the opening FEND. Standard HDLC frames do not. The RNode protocol is a KISS superset with RNode-specific command extensions.
1.2 Command Set (complete table)
Configuration Commands (Host -> Device, Device -> Host as confirmation)
| Command | Byte | Direction | Payload | Description |
|---|---|---|---|---|
CMD_DATA | 0x00 | Both | Raw packet bytes (KISS-escaped) | Reticulum packet data |
CMD_FREQUENCY | 0x01 | Both | 4 bytes, big-endian, Hz | Set/report operating frequency |
CMD_BANDWIDTH | 0x02 | Both | 4 bytes, big-endian, Hz | Set/report channel bandwidth |
CMD_TXPOWER | 0x03 | Both | 1 byte, dBm | Set/report TX power |
CMD_SF | 0x04 | Both | 1 byte (5-12) | Set/report spreading factor |
CMD_CR | 0x05 | Both | 1 byte (5-8) | Set/report coding rate (4/5 through 4/8) |
CMD_RADIO_STATE | 0x06 | Both | 1 byte: 0x00=off, 0x01=on, 0xFF=ask | Set/report radio on/off state |
CMD_RADIO_LOCK | 0x07 | Device->Host | 1 byte | Report radio lock state |
CMD_DETECT | 0x08 | Both | Host sends 0x73, device responds 0x46 | Device presence detection handshake |
CMD_LEAVE | 0x0A | Host->Device | 0xFF | Host is disconnecting (shutdown notification) |
CMD_ST_ALOCK | 0x0B | Both | 2 bytes, big-endian, value/100 = percent | Short-term airtime limit |
CMD_LT_ALOCK | 0x0C | Both | 2 bytes, big-endian, value/100 = percent | Long-term airtime limit |
CMD_READY | 0x0F | Device->Host | (none meaningful) | Device ready for next TX packet |
Statistics Commands (Device -> Host, unsolicited)
| Command | Byte | Direction | Payload | Description |
|---|---|---|---|---|
CMD_STAT_RX | 0x21 | Device->Host | 4 bytes, big-endian | Total RX packet count |
CMD_STAT_TX | 0x22 | Device->Host | 4 bytes, big-endian | Total TX packet count |
CMD_STAT_RSSI | 0x23 | Device->Host | 1 byte (unsigned + 157 offset) | Last packet RSSI |
CMD_STAT_SNR | 0x24 | Device->Host | 1 byte (signed * 0.25 dB) | Last packet SNR |
CMD_STAT_CHTM | 0x25 | Device->Host | 11 bytes (see below) | Channel time/utilization stats |
CMD_STAT_PHYPRM | 0x26 | Device->Host | 12 bytes (see below) | Physical layer parameters |
CMD_STAT_BAT | 0x27 | Device->Host | 2 bytes: [state, percent] | Battery status |
CMD_STAT_CSMA | 0x28 | Device->Host | 3 bytes: [band, min, max] | CSMA contention window params |
CMD_STAT_TEMP | 0x29 | Device->Host | 1 byte (value - 120 = Celsius) | CPU temperature |
System Commands
| Command | Byte | Direction | Payload | Description |
|---|---|---|---|---|
CMD_BLINK | 0x30 | Host->Device | (unknown) | Blink LED for identification |
CMD_RANDOM | 0x40 | Device->Host | 1 byte | Hardware random byte |
CMD_FB_EXT | 0x41 | Host->Device | 1 byte: 0x00=disable, 0x01=enable | External framebuffer control |
CMD_FB_READ | 0x42 | Both | Host sends 0x01; Device responds with 512 bytes | Read framebuffer |
CMD_FB_WRITE | 0x43 | Host->Device | [line_byte] + [8 bytes line data] | Write framebuffer line |
CMD_BT_CTRL | 0x46 | Host->Device | (unknown) | Bluetooth control |
CMD_PLATFORM | 0x48 | Both | Host sends 0x00; Device responds with platform byte | Query/report platform |
CMD_MCU | 0x49 | Both | Host sends 0x00; Device responds with MCU byte | Query/report MCU type |
CMD_FW_VERSION | 0x50 | Both | Host sends 0x00; Device responds with 2 bytes [major, minor] | Query/report firmware version |
CMD_ROM_READ | 0x51 | Host->Device | (unknown) | Read ROM data |
CMD_RESET | 0x55 | Both | Host sends 0xF8; Device sends 0xF8 on reset | Hard reset / reset notification |
CMD_DISP_READ | 0x66 | Both | Host sends 0x01; Device responds with 1024 bytes | Read display buffer |
Multi-Interface Commands (RNodeMultiInterface only)
| Command | Byte | Direction | Payload | Description |
|---|---|---|---|---|
CMD_INTERFACES | 0x71 | Both | Host queries; Device responds with 2 bytes per interface [vport, type] | List available radio interfaces |
CMD_SEL_INT | 0x1F | Host->Device | 1 byte: interface index | Select subinterface for next command |
CMD_INT0_DATA | 0x00 | Device->Host | Packet data | Data received on interface 0 |
CMD_INT1_DATA | 0x10 | Device->Host | Packet data | Data received on interface 1 |
CMD_INT2_DATA | 0x20 | Device->Host | Packet data | Data received on interface 2 |
CMD_INT3_DATA | 0x70 | Device->Host | Packet data | Data received on interface 3 |
CMD_INT4_DATA | 0x75 | Device->Host | Packet data | Data received on interface 4 |
CMD_INT5_DATA | 0x90 | Device->Host | Packet data | Data received on interface 5 |
CMD_INT6_DATA | 0xA0 | Device->Host | Packet data | Data received on interface 6 |
CMD_INT7_DATA | 0xB0 | Device->Host | Packet data | Data received on interface 7 |
CMD_INT8_DATA | 0xC0 | Device->Host | Packet data | Data received on interface 8 |
CMD_INT9_DATA | 0xD0 | Device->Host | Packet data | Data received on interface 9 |
CMD_INT10_DATA | 0xE0 | Device->Host | Packet data | Data received on interface 10 |
CMD_INT11_DATA | 0xF0 | Device->Host | Packet data | Data received on interface 11 |
Note: CMD_INT8_DATA (0xC0) collides with FEND. This appears to be
an oversight or intentional oddity in the multi-interface protocol. It
means interface 8 data cannot actually be distinguished from a frame
delimiter. In practice, multi-interface devices may not populate all 12
slots.
Error Codes (in CMD_ERROR payload)
| Error | Byte | Description |
|---|---|---|
ERROR_INITRADIO | 0x01 | Radio initialization failed |
ERROR_TXFAILED | 0x02 | Transmission failed |
ERROR_EEPROM_LOCKED | 0x03 | EEPROM is locked |
ERROR_QUEUE_FULL | 0x04 | TX queue full (single-interface only) |
ERROR_MEMORY_LOW | 0x05 | Memory exhausted (single-interface only) |
ERROR_MODEM_TIMEOUT | 0x06 | Modem communication timeout (single-interface only) |
Platform Constants
| Platform | Byte | Description |
|---|---|---|
PLATFORM_AVR | 0x90 | AVR-based RNode |
PLATFORM_ESP32 | 0x80 | ESP32-based RNode |
PLATFORM_NRF52 | 0x70 | nRF52-based RNode |
Radio Chip Types (Multi-Interface only)
| Chip | Byte | Frequency Range |
|---|---|---|
SX127X | 0x00 | Sub-GHz (137 MHz - 1 GHz) |
SX1276 | 0x01 | Sub-GHz |
SX1278 | 0x02 | Sub-GHz |
SX126X | 0x10 | Sub-GHz |
SX1262 | 0x11 | Sub-GHz |
SX128X | 0x20 | 2.4 GHz (2.2 GHz - 2.6 GHz) |
SX1280 | 0x21 | 2.4 GHz |
1.3 Initialization Sequence
The host performs this exact sequence after opening the serial port:
Step 1: Open serial port
Baud: 115200
Data bits: 8
Stop bits: 1
Parity: None
Flow control: None (xonxoff=False, rtscts=False, dsrdtr=False)
Timeout: 0 (non-blocking reads)
Step 2: Wait 2.0 seconds
This is a hard-coded sleep to let the device settle after USB enumeration or power-on. Critical for reliability.
Step 3: Start read loop thread
A background thread begins reading bytes from the serial port and parsing KISS frames.
Step 4: Send detect + query commands (single frame sequence)
C0 08 73 C0 50 00 C0 48 00 C0 49 00 C0
This decodes as four back-to-back KISS frames:
FEND CMD_DETECT DETECT_REQ(0x73) FEND– “Are you an RNode?”CMD_FW_VERSION 0x00 FEND– “What firmware version?”CMD_PLATFORM 0x00 FEND– “What platform?”CMD_MCU 0x00 FEND– “What MCU?”
Note: Frames 2-4 rely on FEND at end of previous frame serving as start of next frame (KISS allows this).
For RNodeMultiInterface, an additional query is appended:
5. CMD_INTERFACES 0x00 FEND – “List your radio interfaces”
Step 5: Wait for detect response (200ms for serial, 5s for TCP/BLE)
The read loop parses incoming bytes. When it sees CMD_DETECT with payload
0x46 (DETECT_RESP), it sets self.detected = True. The FW_VERSION,
PLATFORM, and MCU responses are also parsed and stored.
Step 6: Validate firmware version
Required minimum: major >= 1, minor >= 52 (for single-interface). Required minimum: major >= 1, minor >= 74 (for multi-interface).
Step 7: Configure radio parameters
Sends these commands in sequence:
CMD_FREQUENCYwith 4-byte big-endian frequency in HzCMD_BANDWIDTHwith 4-byte big-endian bandwidth in HzCMD_TXPOWERwith 1-byte TX power in dBmCMD_SFwith 1-byte spreading factor (5-12)CMD_CRwith 1-byte coding rate (5-8)CMD_ST_ALOCKwith 2-byte short-term airtime limit (if configured)CMD_LT_ALOCKwith 2-byte long-term airtime limit (if configured)CMD_RADIO_STATEwith0x01(RADIO_STATE_ON)
For multi-interface: each command is preceded by CMD_SEL_INT with the
subinterface index, and configurations are sent per-subinterface.
Step 8: Validate radio state
Wait 250ms (serial) / 1.0s (BLE) / 1.5s (TCP), then compare the
device-reported values (r_frequency, r_bandwidth, etc.) against the
configured values. Frequency must match within 100 Hz.
Step 9: Mark interface online
Wait 300ms, then set self.online = True.
1.4 Data Transfer
Outgoing (Host -> Device)
A Reticulum packet is wrapped as:
[FEND 0xC0] [CMD_DATA 0x00] [KISS-escaped packet bytes] [FEND 0xC0]
For multi-interface, data is sent as:
[FEND] [CMD_SEL_INT 0x1F] [interface_index] [FEND] [FEND] [CMD_DATA 0x00] [KISS-escaped packet bytes] [FEND]
The packet bytes are the raw Reticulum packet (header + payload), with NO additional metadata. RSSI/SNR are not included in outgoing packets.
Incoming (Device -> Host)
The device sends received packets as:
[FEND 0xC0] [CMD_DATA 0x00] [KISS-escaped packet bytes] [FEND 0xC0]
For multi-interface, the device uses interface-specific data commands:
[FEND 0xC0] [CMD_INTn_DATA] [KISS-escaped packet bytes] [FEND 0xC0]
where CMD_INTn_DATA indicates which radio interface received the packet.
Accompanying metadata (separate KISS frames, sent before the data frame):
The device sends RSSI and SNR as separate KISS frames before or after the data frame:
CMD_STAT_RSSI(0x23): 1 byte, unsigned. Actual RSSI = value - 157 dBmCMD_STAT_SNR(0x24): 1 byte, signed. Actual SNR = value * 0.25 dB
These are stored on the interface object and cleared after process_incoming
delivers the packet:
self.r_stat_rssi = None
self.r_stat_snr = None
Periodically reported statistics (unsolicited, from device)
The device periodically sends these frames without host request:
CMD_STAT_CHTM (0x25) – Channel Time, 11 bytes:
Bytes 0-1: airtime_short (BE u16, /100 = percent)
Bytes 2-3: airtime_long (BE u16, /100 = percent)
Bytes 4-5: channel_load_short (BE u16, /100 = percent)
Bytes 6-7: channel_load_long (BE u16, /100 = percent)
Byte 8: current_rssi (unsigned, -157 offset)
Byte 9: noise_floor (unsigned, -157 offset)
Byte 10: interference (unsigned, -157 offset; 0xFF = no interference)
Note: For multi-interface (RNodeMultiInterface), CMD_STAT_CHTM is only 8 bytes (no RSSI/noise_floor/interference fields):
Bytes 0-1: airtime_short (BE u16, /100 = percent)
Bytes 2-3: airtime_long (BE u16, /100 = percent)
Bytes 4-5: channel_load_short (BE u16, /100 = percent)
Bytes 6-7: channel_load_long (BE u16, /100 = percent)
CMD_STAT_PHYPRM (0x26) – Physical Parameters, 12 bytes (single) / 10 bytes (multi):
Bytes 0-1: symbol_time (BE u16, /1000 = milliseconds)
Bytes 2-3: symbol_rate (BE u16, baud)
Bytes 4-5: preamble_symbols (BE u16)
Bytes 6-7: preamble_time (BE u16, milliseconds)
Bytes 8-9: csma_slot_time (BE u16, milliseconds)
Bytes 10-11: difs_time (BE u16, milliseconds) -- ONLY in single-interface
CMD_STAT_CSMA (0x28) – CSMA Parameters, 3 bytes (single-interface only):
Byte 0: contention_window_band
Byte 1: contention_window_min
Byte 2: contention_window_max
CMD_STAT_BAT (0x27) – Battery Status, 2 bytes:
Byte 0: battery_state (0x00=unknown, 0x01=discharging, 0x02=charging, 0x03=charged)
Byte 1: battery_percent (0-100, clamped)
CMD_STAT_TEMP (0x29) – CPU Temperature, 1 byte:
Byte 0: temperature + 120 (actual temp = value - 120 Celsius)
Valid range: -30 to +90 Celsius
1.5 Flow Control
The RNode protocol implements software flow control via the CMD_READY mechanism:
- When
flow_control=Trueis configured, the host setsinterface_ready = Falseafter sending each packet. - The device sends a
CMD_READYframe when it has finished transmitting and is ready for the next packet. - Upon receiving
CMD_READY, the host callsprocess_queue():- If packets are queued, pops the first one and sends it.
- If no packets are queued, sets
interface_ready = True.
- If
interface_readyis False whenprocess_outgoing()is called, the packet is appended topacket_queueinstead of being sent immediately.
When flow_control=False (the default), interface_ready starts True and
is never set to False by the host. The CMD_READY frames from the device
still trigger process_queue(), which is a no-op if the queue is empty.
The packet queue is a simple FIFO list with no maximum size and no priority. Overflow is not explicitly handled.
2. Radio Configuration
2.1 Parameters and Encoding
Frequency (CMD_FREQUENCY, 0x01)
- 4 bytes, big-endian unsigned integer
- Unit: Hertz
- Example: 868.0 MHz =
0x33B13B40 - Encoding:
[freq >> 24, (freq >> 16) & 0xFF, (freq >> 8) & 0xFF, freq & 0xFF] - KISS-escaped after encoding
Bandwidth (CMD_BANDWIDTH, 0x02)
- 4 bytes, big-endian unsigned integer
- Unit: Hertz
- Encoding identical to frequency
- Valid range: 7,800 Hz to 1,625,000 Hz
TX Power (CMD_TXPOWER, 0x03)
- 1 byte, unsigned for single-interface (0-37 dBm)
- 1 byte, signed for multi-interface (-9 to +37 dBm). Encoded as
txpower.to_bytes(1, signed=True)on send; decoded asbyte - 256 if byte > 127 else byteon receive. - Unit: dBm
Spreading Factor (CMD_SF, 0x04)
- 1 byte, unsigned
- Valid range: 5-12
Coding Rate (CMD_CR, 0x05)
- 1 byte, unsigned
- Valid range: 5-8
- Represents 4/5 through 4/8
Short-term Airtime Limit (CMD_ST_ALOCK, 0x0B)
- 2 bytes, big-endian unsigned integer
- Encoding:
int(percent * 100)– so 50.0% becomes 5000 - KISS-escaped after encoding
Long-term Airtime Limit (CMD_LT_ALOCK, 0x0C)
- Same encoding as ST_ALOCK
Radio State (CMD_RADIO_STATE, 0x06)
- 1 byte
0x00= OFF,0x01= ON,0xFF= ASK (query current state)
2.2 Hardware Variants
The Python code identifies devices by platform and MCU:
Platforms:
| Platform | Value | Has Display | Notes |
|---|---|---|---|
| AVR | 0x90 | No | Original Arduino-based RNode |
| ESP32 | 0x80 | Yes | Most common modern RNode |
| NRF52 | 0x70 | Yes | Nordic-based RNode |
Radio chips (Multi-Interface only):
| Chip Family | Frequency Range | Notes |
|---|---|---|
| SX127X (SX1276/SX1278) | 137 MHz - 1 GHz | Sub-GHz LoRa |
| SX126X (SX1262) | 137 MHz - 1 GHz | Sub-GHz LoRa, newer |
| SX128X (SX1280) | 2.2 GHz - 2.6 GHz | 2.4 GHz LoRa |
Frequency validation:
- Single-interface: 137 MHz to 3 GHz (broad range, device validates further)
- Multi-interface SX127X/SX126X: 137 MHz to 1 GHz
- Multi-interface SX128X: 2.2 GHz to 2.6 GHz
Hardware MTU: Fixed at 508 bytes for all RNode variants.
2.3 Firmware Detection
The host queries firmware version as part of the detect sequence:
FEND CMD_FW_VERSION 0x00 FEND
Response is 2 KISS-escaped bytes: [major, minor].
Required minimum versions:
- RNodeInterface (single radio): 1.52
- RNodeMultiInterface (multi radio): 1.74
If firmware is below minimum, Python calls RNS.panic() with instructions
to update via rnodeconf.
Validation logic:
if maj_version > REQUIRED_MAJ:
firmware_ok = True
elif maj_version >= REQUIRED_MAJ and min_version >= REQUIRED_MIN:
firmware_ok = True
2.4 Transport Variants: USB, TCP, BLE
The RNode can be accessed over three transport types. The serial protocol is identical over all three; only the physical transport differs.
USB Serial (default)
- Baud: 115200, 8N1
- Uses pyserial
Serialobject directly - Read timeout: 100ms
- Detect wait: 200ms
TCP (port specified as tcp://hostname)
- Port: 7633 (TCPConnection.TARGET_PORT)
- Uses raw TCP socket with
TCP_NODELAY - Has keepalive mechanism: sends detect command every 3.5s (ACTIVITY_KEEPALIVE = ACTIVITY_TIMEOUT - 2.5 = 3.5s)
- Read timeout: 1500ms
- Detect wait: 5.0s
- TCP keepalive probes: every 2s, after 5s idle, 12 probes, 24s user timeout
BLE (port specified as ble:// or ble://name or ble://AA:BB:CC:DD:EE:FF)
- Uses Nordic UART Service (NUS) over BLE GATT:
- Service:
6E400001-B5A3-F393-E0A9-E50E24DCCA9E - RX Char:
6E400002-B5A3-F393-E0A9-E50E24DCCA9E(host writes to device) - TX Char:
6E400003-B5A3-F393-E0A9-E50E24DCCA9E(device notifies host)
- Service:
- Requires device to be bonded (paired)
- Read timeout: 1250ms
- Detect wait: 5.0s
- Uses
bleakPython library for BLE access - Write chunk size limited to
max_write_without_response_size
3. Medium Access
3.1 CSMA/CA
CSMA is handled entirely by the RNode firmware, not the host.
The host has no CSMA logic. It simply sends packets to the device. The device reports its CSMA parameters back to the host for informational/ monitoring purposes via:
CMD_STAT_PHYPRM(0x26): symbol time, symbol rate, preamble symbols, preamble time, CSMA slot time, DIFS timeCMD_STAT_CSMA(0x28): contention window band, min, max
The host stores these values but does not use them for transmission decisions. All carrier sensing, backoff, and collision avoidance is performed by the RNode firmware.
3.2 Airtime Calculation
On-air bitrate calculation (host-side, for capacity planning):
bitrate = sf * (4.0 / cr) / (2**sf / (bandwidth/1000)) * 1000
Where:
sf= spreading factor (5-12)cr= coding rate (5-8, representing 4/5 through 4/8)bandwidth= channel bandwidth in Hz
This gives the effective data rate in bits per second.
Channel utilization (device-reported):
The device periodically sends CMD_STAT_CHTM with:
r_airtime_short: Short-term airtime percentage (own TX)r_airtime_long: Long-term airtime percentage (own TX)r_channel_load_short: Short-term channel load (all observed activity)r_channel_load_long: Long-term channel load (all observed activity)
The host does NOT compute channel utilization itself. It relies entirely on the device’s reporting.
Airtime limiting:
If configured, the host sends airtime limits to the device:
CMD_ST_ALOCK: Short-term airtime limit percentageCMD_LT_ALOCK: Long-term airtime limit percentage
The device enforces these limits in firmware.
3.3 Timing and Jitter
The RNode interface applies NO send-side jitter or timing delays.
Unlike Transport’s PATHFINDER_RW (0.5s random window for announce
rebroadcasts), the RNode interface has no equivalent jitter mechanism.
All timing is either:
- Device-side CSMA (carrier sensing in firmware)
- Transport-layer announce scheduling (handled by Transport, not the interface)
- Announce rate cap (in base Interface class, based on bitrate):
Defaulttx_time = (len(packet) * 8) / self.bitrate wait_time = tx_time / announce_capannounce_cap= 2% of interface bandwidth.
The 80ms sleep in the read loop idle path is purely to prevent busy-waiting when no data is available, not a timing mechanism.
Callsign beaconing:
If id_interval and id_callsign are configured, the interface
periodically transmits the callsign as raw packet data (not a Reticulum
packet). The timer resets on each TX. The first_tx timestamp records
when the first actual (non-callsign) packet was transmitted.
4. Queue Management
4.1 TX Pipeline
process_outgoing(data)
|
|-- Is interface online?
| No -> drop silently
|
|-- Is interface_ready?
| No -> queue(data) -> append to self.packet_queue
| Yes:
| |-- If flow_control: set interface_ready = False
| |-- KISS-escape the data
| |-- Build frame: FEND + CMD_DATA(0x00) + escaped_data + FEND
| |-- serial.write(frame)
| |-- Increment txb counter
Key observations:
packet_queueis an unbounded Python list (no max size)- FIFO ordering, no priority
interface_readystarts as False, set to True only after successful device configuration (in configure_device)- With flow_control=False (default), interface_ready is always True once online, so the queue is never used
- With flow_control=True, the queue drains one packet at a time via CMD_READY callbacks
4.2 RX Pipeline
readLoop() [background thread]
|
|-- Read 1 byte from serial
|-- Parse KISS frame state machine:
| |-- FEND: start new frame, reset command
| |-- First byte after FEND: set as command byte
| |-- CMD_DATA (0x00): accumulate into data_buffer with KISS unescaping
| |-- CMD_* (config): accumulate into command_buffer, parse when complete
| |-- FEND while in CMD_DATA frame: frame complete
|
|-- On complete CMD_DATA frame:
| process_incoming(data_buffer)
| |-- Increment rxb counter
| |-- self.owner.inbound(data, self) [delivers to Transport]
| |-- Clear r_stat_rssi and r_stat_snr
|
|-- On complete CMD_* frame:
| Parse and store in corresponding r_* fields
|
|-- Timeout handling:
| If partial frame and no data for > self.timeout ms:
| Clear buffer, reset state machine
Buffer size limit: self.HW_MTU (508 bytes). If data_buffer reaches
this size, additional bytes are silently dropped until the next FEND.
4.3 Threading Model
Single-interface (RNodeInterface):
Main thread: Read loop thread:
| |
configure_device() --> readLoop() [daemon]
| |
process_outgoing() ---- | <-- serial.read(1)
setFrequency() ---- | --> parse KISS frames
setBandwidth() ---- | --> update r_* fields
... ---- | --> process_incoming() -> owner.inbound()
| --> process_queue() [on CMD_READY]
|
| [80ms sleep when no data]
Both threads access self.serial (the pyserial object). There is NO
explicit locking between the write path (main thread) and the read path
(readLoop thread). pyserial’s internal buffering provides some safety,
but this is technically a race condition in the Python implementation.
For BLE and TCP transports, separate TX/RX queues with locks are used:
ble_rx_lock/ble_tx_locktcp_rx_lock/tcp_tx_lock
Multi-interface (RNodeMultiInterface):
Same model, but the read loop dispatches to the correct sub-interface
based on CMD_INTn_DATA command bytes. The CMD_SEL_INT command in the
read loop updates self.selected_index, which determines which
sub-interface receives configuration confirmations.
5. Interface Lifecycle
5.1 INI Configuration
The [[RNode Interface]] section accepts these config keys:
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | Yes | – | Interface name |
port | string | Yes | – | Serial port path, tcp://host, or ble://... |
frequency | int | Yes | 0 | Operating frequency in Hz |
bandwidth | int | Yes | 0 | Channel bandwidth in Hz |
txpower | int | Yes | 0 | TX power in dBm |
spreadingfactor | int | Yes | 0 | Spreading factor (5-12) |
codingrate | int | Yes | 0 | Coding rate (5-8) |
flow_control | bool | No | False | Enable TX flow control |
id_interval | int | No | None | Callsign beacon interval in seconds |
id_callsign | string | No | None | Callsign for beaconing (max 32 bytes UTF-8) |
airtime_limit_short | float | No | None | Short-term TX airtime limit (0-100%) |
airtime_limit_long | float | No | None | Long-term TX airtime limit (0-100%) |
RNodeMultiInterface adds:
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
port | string | Yes | – | Serial port path |
Each sub-interface is defined as a nested section with:
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
interface_enabled | bool | No | (inherits parent enabled) | Enable this sub-interface |
vport | int | Yes | – | Virtual port index on device |
frequency | int | Yes | – | Frequency in Hz |
bandwidth | int | Yes | – | Bandwidth in Hz |
txpower | int | Yes | – | TX power in dBm |
spreadingfactor | int | Yes | – | Spreading factor |
codingrate | int | Yes | – | Coding rate |
flow_control | bool | No | False | TX flow control |
airtime_limit_short | float | No | None | Short-term airtime limit |
airtime_limit_long | float | No | None | Long-term airtime limit |
outgoing | bool | No | True | Whether TX is allowed |
5.2 Connection Management
Startup:
- Validate configuration parameters
- Open serial port
- If open succeeds: configure_device (detect, init radio, validate)
- If open fails: start reconnect_port thread
Reconnection:
reconnect_port()runs in a loop:- Sleep 5 seconds (
RECONNECT_WAIT) - Try to open port and configure device
- Repeat until
onlineordetached
- Sleep 5 seconds (
- The readLoop also triggers reconnection when it catches an exception (serial port error, device reset, etc.)
- ESP32 devices send
CMD_RESET 0xF8when they reset while online, which the host treats as an error triggering reconnection.
Shutdown (detach):
- Set
self.detached = True - Disable external framebuffer
- Set radio state to OFF
- Send CMD_LEAVE
- Close BLE/TCP connections if applicable
Ingress limiting:
RNodeInterface overrides should_ingress_limit() to always return
False. This means RNode interfaces never throttle incoming announces
at the interface level (Transport still applies its own limiting).
5.3 Statistics
The interface tracks and exposes:
Counters (host-maintained):
rxb: Total bytes received (incremented in process_incoming)txb: Total bytes transmitted (incremented in process_outgoing)
Device-reported:
r_stat_rx: Total device RX packet count (4-byte)r_stat_tx: Total device TX packet count (4-byte)r_stat_rssi: Last packet RSSI in dBm (byte - 157)r_stat_snr: Last packet SNR in dB (signed_byte * 0.25)r_stat_q: Signal quality percentage (computed from SNR and SF)r_airtime_short/r_airtime_long: TX airtime percentagesr_channel_load_short/r_channel_load_long: Channel load percentagesr_battery_state/r_battery_percent: Battery infor_temperature/cpu_temp: CPU temperature in Celsius
Signal quality calculation:
q_snr_min = Q_SNR_MIN_BASE - (sf - 7) * Q_SNR_STEP # where BASE=-9, STEP=2
q_snr_max = Q_SNR_MAX # 6
q_snr_span = q_snr_max - q_snr_min
quality = clamp(((snr - q_snr_min) / q_snr_span) * 100, 0, 100)
RSSI decoding:
All RSSI values use the same offset: actual_dBm = raw_byte - 157.
6. Physical Device Info
Connected Device
Device: /dev/ttyACM0
USB Vendor: 1a86 (QinHeng Electronics)
USB Model: USB Single Serial (55d4)
USB Serial: 5896004228
Driver: cdc_acm
Symlinks: /dev/serial/by-id/usb-1a86_USB_Single_Serial_5896004228-if00
Probe Results
Detection: Successful (DETECT_RESP = 0x46)
Firmware: 1.85
Platform: ESP32 (0x80)
MCU: 0x81
Battery report: Received (CMD_STAT_BAT 0x27, state=0x00 unknown, percent=0%)
The device also sent an unsolicited CMD_STAT_BAT frame during the detect
sequence, which is expected – the device reports battery status
periodically.
The QinHeng Electronics CH340/CH9102 USB-serial chip (VID 1a86, PID 55d4) is commonly used on ESP32 development boards, specifically the Heltec and LilyGO T-Beam variants commonly used for RNode.
7. Implementation Notes for Rust
7.1 What Maps to Our Interface Trait (Send Side)
The outgoing path is straightforward: process_outgoing(data) takes a raw
Reticulum packet and wraps it in a KISS frame. This maps to our Interface
trait’s send method. The KISS framing (FEND + CMD_DATA + escape + FEND) is
a simple transformation.
The flow control queue (interface_ready / packet_queue) is host-side state that should live on the interface struct. When flow_control is enabled, the interface buffers packets until the device signals CMD_READY.
7.2 What Needs Its Own Async Task (Receive Side, Serial I/O)
The Python implementation uses a daemon thread for readLoop(). In our
async Rust architecture, this maps to an async task that:
- Reads bytes from the serial port (async serial I/O)
- Parses the KISS frame state machine
- Dispatches complete frames:
- CMD_DATA -> feed to NodeCore via
handle_packet() - CMD_STAT_* -> update interface metadata
- CMD_READY -> trigger queue drain
- CMD_ERROR -> handle errors
- CMD_DATA -> feed to NodeCore via
The serial port read should use tokio-serial or similar async serial
crate. The KISS deframer runs in the same task (no separate thread needed).
The read loop is the only path that needs to be truly async. All writes (config commands, data packets) can be synchronous or fire-and-forget since there’s no write-side acknowledgment protocol.
7.3 Where Send-Side Jitter Fits
There is no send-side jitter in the RNode interface itself. All timing is handled by:
- RNode firmware: CSMA/CA with carrier sensing
- Transport layer: Announce rebroadcast random window (
PATHFINDER_RW = 0.5s) - Interface base class: Announce rate cap (
announce_cap = 2%)
The announce rate cap and queue management from the base Interface class should be implemented in the transport/driver layer, not in the RNode interface itself. The interface is a dumb pipe – it takes packets from the send queue and KISS-frames them to the serial port.
7.4 State the Interface Needs to Maintain
Configuration (set once):
- frequency, bandwidth, txpower, sf, cr
- st_alock, lt_alock
- flow_control flag
- id_callsign, id_interval
- port path, transport type (USB/TCP/BLE)
Device-reported (updated from read loop):
- r_frequency, r_bandwidth, r_txpower, r_sf, r_cr, r_state, r_lock
- r_stat_rssi, r_stat_snr (per-packet, cleared after delivery)
- r_airtime_short, r_airtime_long, r_channel_load_short, r_channel_load_long
- r_symbol_time_ms, r_symbol_rate, r_preamble_symbols, r_preamble_time_ms
- r_csma_slot_time_ms, r_csma_difs_ms
- r_csma_cw_band, r_csma_cw_min, r_csma_cw_max
- r_battery_state, r_battery_percent, r_temperature
- detected, firmware_ok, maj_version, min_version
- platform, mcu, display
Runtime (host-managed):
- online flag
- interface_ready flag (for flow control)
- packet_queue (if flow_control enabled)
- rxb, txb counters
- first_tx timestamp (for callsign beaconing)
7.5 Relationship to Existing KISS Framing in reticulum-core
The existing framing code in reticulum-core/src/framing/hdlc.rs is
HDLC framing, NOT KISS framing. They are different protocols.
Key differences:
| Property | HDLC (existing) | KISS (needed for RNode) |
|---|---|---|
| Flag byte | 0x7E | 0xC0 (FEND) |
| Escape byte | 0x7D | 0xDB (FESC) |
| Escape method | XOR with 0x20 | Substitution: 0xDC (TFEND) or 0xDD (TFESC) |
| Command byte | None | First byte after FEND is command |
| Used for | TCP interfaces | Serial RNode interface |
We need a new framing/kiss.rs module alongside the existing hdlc.rs.
The module structure should be:
framing/
mod.rs -- re-exports both
hdlc.rs -- existing, for TCP
kiss.rs -- new, for RNode serial
The KISS module needs:
- Constants: FEND, FESC, TFEND, TFESC
fn kiss_escape(data: &[u8]) -> Vec<u8>fn kiss_frame(cmd: u8, data: &[u8]) -> Vec<u8>struct KissDeframerwith state machine for parsing incoming bytes (tracking command byte, escape state, buffer)
The KissDeframer should yield (command: u8, data: Vec<u8>) tuples,
not raw byte buffers like the HDLC Deframer.
7.6 Is This Standard KISS or a Superset?
It is a KISS superset. Standard KISS TNC protocol (as used in amateur radio) defines:
| Standard KISS | RNode Extension |
|---|---|
CMD_DATA (0x00) | Same |
CMD_TXDELAY (0x01) | Repurposed as CMD_FREQUENCY |
CMD_P (0x02) | Repurposed as CMD_BANDWIDTH |
CMD_SLOTTIME (0x03) | Repurposed as CMD_TXPOWER |
CMD_TXTAIL (0x04) | Repurposed as CMD_SF |
CMD_FULLDUPLEX (0x05) | Repurposed as CMD_CR |
CMD_SETHARDWARE (0x06) | Repurposed as CMD_RADIO_STATE |
CMD_RETURN (0xFF) | Not used by RNode |
| (none) | 0x07-0x0F: RNode-specific config commands |
| (none) | 0x21-0x29: RNode statistics |
| (none) | 0x30-0x55: RNode system commands |
| (none) | 0x66: Display read |
| (none) | 0x71, 0x1F: Multi-interface commands |
| (none) | 0x90: Error reporting |
The framing layer (FEND/FESC/TFEND/TFESC) is identical to standard KISS. The command bytes 0x01-0x06 overlap with standard KISS but have completely different semantics (frequency vs. txdelay, etc.).
This means our KISS framing module should implement the framing layer
generically, and the RNode command interpretation should be in a separate
module (e.g., interfaces/rnode.rs in reticulum-std).
7.7 Architecture Mapping
reticulum-core/src/framing/kiss.rs -- KISS framing (FEND/FESC escaping)
Layer 0, no_std compatible
Pure data transformation, no I/O
reticulum-std/src/interfaces/rnode.rs -- RNode interface implementation
Owns serial port (async I/O)
KISS command interpretation
Radio configuration state machine
Flow control queue management
reticulum-std/src/driver/ -- Existing driver integrates RNode
interface alongside TCP
The KISS framing module belongs in reticulum-core because it’s a pure data transformation (like HDLC). The RNode interface logic belongs in reticulum-std because it performs I/O (serial port access).
7.8 Implementation Priority
For a minimal working RNode interface:
- KISS framing module (framing/kiss.rs) – escape/unescape, frame/deframe
- RNode command parser – interpret command bytes and payloads
- Initialization sequence – detect, query, configure, validate
- Data path – TX: KISS-frame packets; RX: deframe and deliver
- Statistics – parse RSSI/SNR/channel stats from device
- Flow control – CMD_READY queue management
- Reconnection – handle disconnect/reconnect
- Multi-interface support – CMD_SEL_INT, CMD_INTn_DATA
BLE and TCP transport support for RNode can be deferred; USB serial is the primary use case.
SX1261/2 Datasheet Reference (Driver Development Extract)
Source: Semtech SX1261/2 Data Sheet, Rev 2.2, DS.SX1261-2.W.APP, December 2024.
This document extracts the sections relevant for SX1262 LoRa driver development. For the complete datasheet, see semtech.com.
8. Digital Interface and Control
The SX1261/2 is controlled via a serial SPI interface and a set of general purpose input/output (DIOs). At least one DIO must be used for IRQ and the BUSY line is mandatory. BUSY indicates that the chip is ready for new command only if this signal is low.
8.1 Reset
A complete “factory reset” can be issued by toggling pin NRESET. It is automatically followed by the standard calibration procedure and any previous context is lost. The pin should be held low for typically 100us for the Reset to happen.
8.2 SPI Interface
The SPI interface uses a synchronous full-duplex protocol: CPOL = 0, CPHA = 0 (Mode 0). Only the slave side is implemented.
- MOSI is generated by the master on the falling edge of SCK and is sampled by the slave on the rising edge of SCK.
- MISO is generated by the slave on the falling edge of SCK.
- A transfer is always started by the NSS pin going low. MISO is high impedance when NSS is high.
- SPI runs on the external SCK clock to allow high speed up to 16 MHz.
SPI Timing Requirements (Table 8-1)
| Symbol | Description | Min | Max | Unit |
|---|---|---|---|---|
| t1 | NSS falling to SCK setup time | 32 | - | ns |
| t2 | SCK period | 62.5 | - | ns |
| t6 | NSS falling to MISO delay | 0 | 15 | ns |
| t7 | SCK falling to MISO delay | 0 | 15 | ns |
| t8 | SCK to NSS rising hold time | 31.25 | - | ns |
| t9 | NSS high time | 125 | - | ns |
| t10 | NSS falling to SCK setup when switching from SLEEP to STDBY_RC | 100 | - | us |
| t11 | NSS falling to MISO delay when switching from SLEEP to STDBY_RC | 0 | 150 | us |
8.2.2 SPI Timing When Leaving Sleep Mode
One way for the chip to leave Sleep mode is to wait for a falling edge of NSS. The delay between the falling edge of NSS and the first rising edge of SCK must take into account the wake-up sequence and the chip initialization. During Sleep mode and the initialization phase, BUSY is set high. Once the chip is in STDBY_RC mode, BUSY goes low and the host can start sending a command. This is also true for startup at battery insertion or after a hard reset.
8.3.1 BUSY Control Line
The BUSY control line indicates the status of the internal state machine. When BUSY is held low, the internal state machine is in idle mode and the radio is ready for a command.
For all “write” commands, BUSY is asserted high after time T_SW. T_SW from NSS rising edge to BUSY rising edge is max 600 ns in all cases.
“Read” commands are handled directly without the internal state machine and BUSY remains low after a read command.
Switching Times (Table 8-2)
| Transition | T_SW_Mode Typical (us) |
|---|---|
| SLEEP to STBY_RC cold start | 3500 |
| SLEEP to STBY_RC warm start | 340 |
| STBY_RC to STBY_XOSC | 31 |
| STBY_RC to FS | 50 |
| STBY_RC to RX | 83 |
| STBY_RC to TX | 126 |
| STBY_XOSC to TX | 105 |
8.4 Digital Interface Status versus Chip Modes (Table 8-3)
| Mode | DIO3 | DIO2 | DIO1 | BUSY | MISO | MOSI | SCK | NSS |
|---|---|---|---|---|---|---|---|---|
| Reset | PD | PD | PD | PU | HIZ | HIZ | HIZ | IN |
| Start-up | PD | PD | PD | PU | HIZ | HIZ | HIZ | IN |
| Sleep | PD | PD | PD | PU | HIZ | HIZ | HIZ | IN |
| STBY_RC | OUT | OUT | OUT | OUT | OUT | IN | IN | IN |
| STBY_XOSC | OUT | OUT | OUT | OUT | OUT | IN | IN | IN |
| FS / RX / TX | OUT | OUT | OUT | OUT | OUT | IN | IN | IN |
PU = pull up 50kOhm, PD = pull down 50kOhm, HIZ = high impedance, OUT = output, IN = input.
During Reset, Start-up, and Sleep: MISO is High-Impedance. Any SPI read during these states returns undefined data.
8.5 IRQ Handling (Table 8-4)
| Bit | IRQ | Description | Modulation |
|---|---|---|---|
| 0 | TxDone | Packet transmission completed | All |
| 1 | RxDone | Packet received | All |
| 2 | PreambleDetected | Preamble detected | All |
| 3 | SyncWordValid | Valid Sync Word detected | FSK |
| 4 | HeaderValid | Valid LoRa Header received | LoRa |
| 5 | HeaderErr | LoRa Header CRC error | LoRa |
| 6 | CrcErr | Wrong CRC received | All |
| 7 | CadDone | Channel activity detection finished | LoRa |
| 8 | CadDetected | Channel activity detected | LoRa |
| 9 | Timeout | Rx or Tx Timeout | All |
Note: If DIO2 or DIO3 are used to control the RF Switch or the TCXO, the IRQ is not generated even if it is mapped to the pins.
9. Operational Modes
Operating Modes (Table 9-1)
| Mode | Enabled Blocks |
|---|---|
| SLEEP | Optional registers, backup regulator, RC64k oscillator, data RAM |
| STDBY_RC | Top regulator (LDO), RC13M oscillator |
| STDBY_XOSC | Top regulator (DC-DC or LDO), XOSC |
| FS | All of the above + Frequency synthesizer at Tx frequency |
| TX | Frequency synthesizer and transmitter, Modem |
| RX | Frequency synthesizer and receiver, Modem |
9.1 Startup
At power-up or after a reset, the chip goes into STARTUP state. The BUSY pin is set to high. When the digital voltage and RC clock become available, the chip can boot up and the CPU takes control. At this stage the BUSY line goes down and the device is ready to accept commands.
9.2 Calibration
The calibration procedure is automatically called in case of POR. Blocks calibrated: RC64k, RC13M, PLL, RX ADC, Image. Once calibration is finished, the chip enters STDBY_RC mode.
9.2.1 Image Calibration for Specific Frequency Bands (Table 9-2)
| Frequency Band [MHz] | Freq1 | Freq2 |
|---|---|---|
| 430 - 440 | 0x6B | 0x6F |
| 470 - 510 | 0x75 | 0x81 |
| 779 - 787 | 0xC1 | 0xC5 |
| 863 - 870 | 0xD7 | 0xDB |
| 902 - 928 | 0xE1 (default) | 0xE9 (default) |
By default, the image calibration is made in the 902-928 MHz band. When using a TCXO, the calibration fails and the user should request a complete calibration after calling SetDIO3AsTcxoCtrl(...).
9.7 Transmit (TX) Mode
In TX mode, after enabling and ramping-up the Power Amplifier (PA), the contents of the data buffer are transmitted. The timeout can be used as a security to ensure that if the TxDone IRQ is never triggered, the TxTimeout prevents waiting indefinitely. In TX mode, BUSY goes low as soon as the PA has ramped-up and transmission of preamble starts.
10. Host Controller Interface
10.1 Command Structure (Table 10-1)
| Byte | 0 | [1:n] |
|---|---|---|
| Data from host (MOSI) | Opcode | Parameters |
| Data to host (MISO) | RFU | Status |
During byte 0 (the opcode byte), MISO returns RFU (Reserved for Future Use) – NOT the status byte. The status byte appears starting at byte 1.
10.2 Transaction Termination
The host terminates an SPI transaction with the rising NSS signal. The host must not raise NSS within the bytes of a transaction. All parameters must be sent before raising NSS.
11. List of Commands
11.1 Operational Mode Commands (Table 11-1)
| Command | Opcode | Parameters | Description |
|---|---|---|---|
| SetSleep | 0x84 | sleepConfig | Set Chip in SLEEP mode |
| SetStandby | 0x80 | standbyConfig | Set Chip in STDBY_RC or STDBY_XOSC mode |
| SetFs | 0xC1 | - | Set Chip in Frequency Synthesis mode |
| SetTx | 0x83 | timeout[23:0] | Set Chip in Tx mode |
| SetRx | 0x82 | timeout[23:0] | Set Chip in Rx mode |
| SetCad | 0xC5 | - | Set chip in RX mode with CAD parameters |
| SetTxContinuousWave | 0xD1 | - | Test command: CW at selected frequency |
| SetRegulatorMode | 0x96 | regModeParam | Select LDO or DC_DC+LDO |
| Calibrate | 0x89 | calibParam | Calibrate RC13, RC64, ADC, PLL, Image |
| CalibrateImage | 0x98 | freq1, freq2 | Image calibration at given frequencies |
| SetPaConfig | 0x95 | paDutyCycle, HpMax, deviceSel, paLUT | Configure PA |
| SetRxTxFallbackMode | 0x93 | fallbackMode | Mode after TX/RX done |
11.2 Register and Buffer Access Commands (Table 11-2)
| Command | Opcode | Parameters |
|---|---|---|
| WriteRegister | 0x0D | address[15:0], data[0:n] |
| ReadRegister | 0x1D | address[15:0] |
| WriteBuffer | 0x0E | offset, data[0:n] |
| ReadBuffer | 0x1E | offset |
11.3 DIO and IRQ Control (Table 11-3)
| Command | Opcode | Parameters |
|---|---|---|
| SetDioIrqParams | 0x08 | IrqMask[15:0], Dio1Mask[15:0], Dio2Mask[15:0], Dio3Mask[15:0] |
| GetIrqStatus | 0x12 | - |
| ClearIrqStatus | 0x02 | ClearIrqParam[15:0] |
| SetDIO2AsRfSwitchCtrl | 0x9D | enable |
| SetDIO3AsTcxoCtrl | 0x97 | tcxoVoltage, timeout[23:0] |
11.4 RF, Modulation and Packet Commands (Table 11-4)
| Command | Opcode | Parameters |
|---|---|---|
| SetRfFrequency | 0x86 | rfFreq[31:0] |
| SetPacketType | 0x8A | protocol |
| SetTxParams | 0x8E | power, rampTime |
| SetModulationParams | 0x8B | ModParam1..8 |
| SetPacketParams | 0x8C | (preamble, header, payload, crc, iq) |
| SetBufferBaseAddress | 0x8F | TX base address, RX base address |
| SetLoRaSymbNumTimeout | 0xA0 | SymbNum |
13. Command Details (Selected)
13.1.2 SetStandby
| Byte | 0 | 1 |
|---|---|---|
| Data from host | 0x80 | StdbyConfig |
StdbyConfig: 0 = STDBY_RC, 1 = STDBY_XOSC.
13.1.4 SetTx
| Byte | 0 | 1-3 |
|---|---|---|
| Data from host | 0x83 | timeout[23:0] |
- Starting from STDBY_RC mode, the oscillator is switched ON followed by PLL, then the PA ramps up.
- When the last bit has been sent, an IRQ TX_DONE is generated, the PA ramps down, and the chip goes back to STDBY_RC mode.
- A TIMEOUT IRQ is triggered if TX_DONE is not generated within the timeout period.
- Timeout duration = Timeout * 15.625us
- Timeout = 0x000000: No timeout, device stays in TX until packet is transmitted and returns to STBY_RC.
13.1.5 SetRx
| Byte | 0 | 1-3 |
|---|---|---|
| Data from host | 0x82 | timeout[23:0] |
| Timeout | Duration |
|---|---|
| 0x000000 | Single mode: stays in RX until reception, then returns to STBY_RC |
| 0xFFFFFF | Continuous mode: remains in RX until host sends a mode change command |
| Others | Timeout active: returns to STBY_RC on timeout or reception. Max timeout is 262s. |
13.1.11 SetRegulatorMode
| Byte | 0 | 1 |
|---|---|---|
| Data from host | 0x96 | regModeParam |
regModeParam: 0 = Only LDO, 1 = DC_DC+LDO (used for STBY_XOSC, FS, RX and TX modes).
13.1.12 Calibrate Function
| Byte | 0 | 1 |
|---|---|---|
| Data from host | 0x89 | calibParam |
calibParam is a bitmask: Bit 0=RC64k, 1=RC13M, 2=PLL, 3=ADC pulse, 4=ADC bulk N, 5=ADC bulk P, 6=Image. 0x7F = calibrate all. Total calibration time ~3.5ms. BUSY is high during calibration.
13.1.14 SetPaConfig
| Byte | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| Data from host | 0x95 | paDutyCycle | hpMax | deviceSel | paLut |
- deviceSel: 0 = SX1262, 1 = SX1261
- paLut: reserved, always 0x01
- For SX1262, paDutyCycle should not be higher than 0x04.
PA Optimal Settings (Table 13-21)
| Output Power | paDutyCycle | hpMax | deviceSel | paLut | SetTxParams power |
|---|---|---|---|---|---|
| +22dBm | 0x04 | 0x07 | 0x00 | 0x01 | +22dBm |
| +20dBm | 0x03 | 0x05 | 0x00 | 0x01 | +22dBm |
| +17dBm | 0x02 | 0x03 | 0x00 | 0x01 | +22dBm |
| +14dBm | 0x02 | 0x02 | 0x00 | 0x01 | +22dBm |
13.1.15 SetRxTxFallbackMode
| Byte | 0 | 1 |
|---|---|---|
| Data from host | 0x93 | fallbackMode |
| Fallback Mode | Value | Description |
|---|---|---|
| FS | 0x40 | Go to FS mode after TX/RX |
| STDBY_XOSC | 0x30 | Go to STDBY_XOSC after TX/RX |
| STDBY_RC | 0x20 | Go to STDBY_RC after TX/RX (default) |
13.2.1 WriteRegister
| Byte | 0 | 1 | 2 | 3 | … | n |
|---|---|---|---|---|---|---|
| MOSI | 0x0D | addr[15:8] | addr[7:0] | data@addr | … | data@addr+(n-3) |
| MISO | RFU | Status | Status | Status | … | Status |
13.2.2 ReadRegister
| Byte | 0 | 1 | 2 | 3 | 4 | … |
|---|---|---|---|---|---|---|
| MOSI | 0x1D | addr[15:8] | addr[7:0] | NOP | NOP | … |
| MISO | RFU | Status | Status | Status | data@addr | … |
Note: The host must send an NOP after the 2 bytes of address to start receiving data bytes on the next NOP sent.
13.2.3 WriteBuffer
| Byte | 0 | 1 | 2 | … | n |
|---|---|---|---|---|---|
| MOSI | 0x0E | offset | data@offset | … | data@offset+(n-2) |
| MISO | RFU | Status | Status | … | Status |
13.2.4 ReadBuffer
| Byte | 0 | 1 | 2 | 3 | … |
|---|---|---|---|---|---|
| MOSI | 0x1E | offset | NOP | NOP | … |
| MISO | RFU | Status | Status | data@offset | … |
Note: An NOP must be sent after sending the offset.
13.3.1 SetDioIrqParams
| Byte | 0 | 1-2 | 3-4 | 5-6 | 7-8 |
|---|---|---|---|---|---|
| Data from host | 0x08 | IrqMask[15:0] | DIO1Mask[15:0] | DIO2Mask[15:0] | DIO3Mask[15:0] |
The interrupt causes a DIO to be set if the corresponding bit in DioxMask AND IrqMask are both set. For example, to route TxDone to DIO1: set bit 0 of both IrqMask and DIO1Mask.
13.3.3 GetIrqStatus
| Byte | 0 | 1 | 2-3 |
|---|---|---|---|
| MOSI | 0x12 | NOP | NOP |
| MISO | RFU | Status | IrqStatus[15:0] |
13.3.4 ClearIrqStatus
| Byte | 0 | 1-2 |
|---|---|---|
| MOSI | 0x02 | ClearIrqParam[15:0] |
13.3.5 SetDIO2AsRfSwitchCtrl
| Byte | 0 | 1 |
|---|---|---|
| MOSI | 0x9D | enable |
enable=1: DIO2 controls RF switch. DIO2=1 during TX, DIO2=0 otherwise.
13.3.6 SetDIO3AsTcxoCtrl
| Byte | 0 | 1 | 2-4 |
|---|---|---|---|
| MOSI | 0x97 | tcxoVoltage | delay[23:0] |
tcxoVoltage (Table 13-35)
| Value | Output Voltage |
|---|---|
| 0x00 | 1.6V |
| 0x01 | 1.7V |
| 0x02 | 1.8V |
| 0x03 | 2.2V |
| 0x06 | 3.0V |
| 0x07 | 3.3V |
Delay duration = delay[23:0] * 15.625us
The XOSC_START_ERR flag is raised at POR or wake-up from Sleep in cold-start condition when TCXO is used. This is expected and should be cleared with ClearDeviceErrors.
Note: The user should take the delay period into account when going into Tx or Rx mode from STDBY_RC mode, since the time needed to switch modes increases with the duration of delay.
13.4.1 SetRfFrequency
| Byte | 0 | 1-4 |
|---|---|---|
| MOSI | 0x86 | RfFreq[31:0] |
RF_frequency = RF_Freq * F_XTAL / 2^25, where F_XTAL = 32 MHz.
To compute RF_Freq from Hz: RF_Freq = freq_hz * 2^25 / 32_000_000
13.4.4 SetTxParams
| Byte | 0 | 1 | 2 |
|---|---|---|---|
| MOSI | 0x8E | power | RampTime |
power: -9 to +22 dBm (encoded as 0xF7 to 0x16) for high power PA (SX1262).
| RampTime | Value | Time (us) |
|---|---|---|
| SET_RAMP_10U | 0x00 | 10 |
| SET_RAMP_20U | 0x01 | 20 |
| SET_RAMP_40U | 0x02 | 40 |
| SET_RAMP_80U | 0x03 | 80 |
| SET_RAMP_200U | 0x04 | 200 |
| SET_RAMP_800U | 0x05 | 800 |
13.4.5 SetModulationParams (LoRa)
| Byte | 0 | 1 | 2 | 3 | 4 | 5-8 |
|---|---|---|---|---|---|---|
| MOSI | 0x8B | SF | BW | CR | LdOpt | unused (0x00) |
- ModParam1 = SF (Spreading Factor)
- ModParam2 = BW (Bandwidth)
- ModParam3 = CR (Coding Rate)
- ModParam4 = LdOpt (Low Data Rate Optimization)
13.5.1 GetStatus
| Byte | 0 | 1 |
|---|---|---|
| MOSI | 0xC0 | NOP |
| MISO | RFU | Status |
Status Byte Format (Table 13-76)
| Bit 7 | Bits 6:4 | Bits 3:1 | Bit 0 |
|---|---|---|---|
| Reserved | Chip mode | Command status | Reserved |
Chip mode:
| Value | Mode |
|---|---|
| 0x0 | Unused |
| 0x2 | STBY_RC |
| 0x3 | STBY_XOSC |
| 0x4 | FS |
| 0x5 | RX |
| 0x6 | TX |
Command status:
| Value | Meaning |
|---|---|
| 0x0 | Reserved |
| 0x2 | Data is available to host |
| 0x3 | Command timeout |
| 0x4 | Command processing error |
| 0x5 | Failure to execute command |
| 0x6 | Command TX done |
13.5.2 GetRxBufferStatus
| Byte | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| MOSI | 0x13 | NOP | NOP | NOP |
| MISO | RFU | Status | PayloadLengthRx | RxStartBufferPointer |
13.5.3 GetPacketStatus (LoRa)
| Byte | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| MOSI | 0x14 | NOP | NOP | NOP | NOP |
| MISO | RFU | Status | RssiPkt | SnrPkt | SignalRssiPkt |
- Actual signal power = -RssiPkt/2 (dBm)
- Actual SNR = SnrPkt/4 (dB)
15. Known Limitations
15.1 Modulation Quality with 500kHz LoRa Bandwidth
Before any packet transmission, bit #2 at register address 0x0889 shall be set to:
- 0 if the LoRa BW = 500kHz
- 1 for any other LoRa BW or (G)FSK configuration
Must be applied before each packet transmission.
15.2 Better Resistance to Antenna Mismatch (TX PA Clamp)
During chip initialization on the SX1262, the register TxClampConfig at address 0x08D8 should be modified. Bits 4-1 must be set to “1111” (default value “0100”).
value = ReadRegister(0x08D8)
value = value | 0x1E
WriteRegister(value, 0x08D8)
Must be done after POR or wake-up from cold start.
15.3 Implicit Header Mode Timeout Behavior
After ANY Rx with Timeout active sequence, stop the RTC and clear the timeout event:
WriteRegister(0x00, 0x0920)
value = ReadRegister(0x0944)
value = value | 0x02
WriteRegister(value, 0x0944)
15.4 Optimizing the Inverted IQ Operation
Bit 2 at address 0x0736 must be set to:
- “0” when using inverted IQ polarity
- “1” when using standard IQ polarity
Key Register Addresses
| Address | Name | Description |
|---|---|---|
| 0x0740 | LoRaSyncword | LoRa sync word (2 bytes, MSB first). 0x1424=private, 0x3444=public. |
| 0x0889 | TxModulation | BW500 workaround (bit 2) |
| 0x08AC | RxGain | 0x94=power saving (default), 0x96=boosted gain |
| 0x08D8 | TxClampConfig | PA clamp workaround (bits 4:1 = 0xF) |
| 0x0736 | IqPolarity | Inverted IQ workaround (bit 2) |
| 0x0902 | RtcControl | RTC stop (write 0x00 after Rx with timeout) |
| 0x0944 | EventMask | Clear timeout event (bit 1) |
| 0x029F | RetentionList count | Number of retention registers |
| 0x02A0-0x02A1 | RetentionList[0] | First retention register address (0x08AC) |
Init Sequence Summary (from Semtech reference driver + RNode)
- Hardware reset (NRESET LOW 100us, HIGH, wait BUSY LOW)
- SetStandby(STBY_RC)
[0x80, 0x00] - SetRegulatorMode(DC_DC)
[0x96, 0x01] - SetDIO2AsRfSwitchCtrl(enable)
[0x9D, 0x01] - ClearDeviceErrors
[0x07, 0x00, 0x00] - SetDIO3AsTcxoCtrl(1.8V, timeout)
[0x97, 0x02, t2, t1, t0] - Calibrate(all)
[0x89, 0x7F]— wait BUSY LOW (~3.5ms) - CalibrateImage(863-870MHz)
[0x98, 0xD7, 0xDB] - SetPacketType(LoRa)
[0x8A, 0x01] - SetRfFrequency(freq)
[0x86, f3, f2, f1, f0] - SetPaConfig(0x04, 0x07, 0x00, 0x01) for +22dBm SX1262
- SetTxParams(power, ramp)
[0x8E, pwr, ramp] - SetBufferBaseAddress(0, 0)
[0x8F, 0x00, 0x00] - SetModulationParams(SF, BW, CR, LDRO)
[0x8B, sf, bw, cr, ldro, 0,0,0,0] - SetPacketParams(preamble, header, len, crc, iq)
[0x8C, ...] - Write LoRa sync word to register 0x0740
- Apply workarounds: TxClamp (0x08D8), BW500 (0x0889), IQ (0x0736)
- Set RxGain to 0x96 (boosted) at register 0x08AC
TX Sequence
- SetDioIrqParams(TxDone|Timeout on DIO1)
[0x08, mask_hi, mask_lo, dio1_hi, dio1_lo, 0,0, 0,0] - ClearIrqStatus(all)
[0x02, 0xFF, 0xFF] - WriteBuffer(0, payload)
[0x0E, 0x00, data...] - SetTx(timeout)
[0x83, t2, t1, t0]— timeout=0 for no timeout - Wait for DIO1 HIGH (TxDone IRQ)
- ClearIrqStatus(TxDone)
[0x02, 0x00, 0x01]
RX Sequence
- SetDioIrqParams(RxDone|Timeout|CrcErr on DIO1)
- ClearIrqStatus(all)
[0x02, 0xFF, 0xFF] - SetRx(timeout)
[0x82, t2, t1, t0] - Wait for DIO1 HIGH
- GetIrqStatus — check RxDone vs Timeout vs CrcErr
- GetRxBufferStatus
[0x13, ...]— get length + start pointer - ReadBuffer(start, length)
[0x1E, start, NOP, data...] - GetPacketStatus
[0x14, ...]— get RSSI, SNR - ClearIrqStatus
- Apply workaround 15.3 (stop RTC after Rx with timeout)
Structured event logs
Test-harness scaffolding (Codeberg #39 piece 1, Stage 6) for capturing mesh-protocol events as parseable lines so multi-node failures can be diagnosed from a single merged log instead of N hand-correlated process traces.
Format
Each emitted event renders to a single line:
EVENT_NAME node=<n> key1=val1 key2=val2 ... t=<rel-ms>
Rules:
EVENT_NAMEfirst. Comes from the literal string passed as theeventfield in atracing::debug!call.node=second. Value comes fromLEVICULUM_EVENT_NODEenvironment variable; defaults tolocal.- All other keys appear alphabetically sorted between
node=andt=. t=last. Millisecond offset from layer registration time.
Records that don’t carry an event = "..." field are silently
dropped, so the legacy printf-style tracing::debug!("[FOO] ...")
sites stay valid alongside the converted ones.
Architecture
All test threads, including tokio multi-thread workers, route through the same global subscriber registered once via Layer composition.
Specifically: tracing_setup::init_tracing_with_event_log() builds a
Registry::default().with(fmt_layer).with(event_log_layer) chain
and installs it via set_global_default once per process (Once-
guarded). Every thread, every spawned future, every tokio worker
inherits this global subscriber. This is the load-bearing
architectural choice that lets a #[tokio::test(multi_thread)] mvr
see events emitted from worker threads.
Per-test buffer isolation is built on top: init_event_log()
returns an EventLogHandle whose Arc<Mutex<Vec<String>>> buffer
is registered in the layer’s active-handles list. The layer’s
on_event iterates the active list and pushes the formatted line
to every active buffer. When the test’s handle drops, it removes
itself from the list.
Concurrency consequence: every active buffer receives every event
the layer sees, regardless of which test emitted it. Tests that
assert on buffer contents must filter by event name to avoid
cross-test pollution. Use disjoint event names per test
(EV_BASIC, EV_VIOLATION, …); mvr tests already enforce
--test-threads=1 so this only affects unit tests.
How to wire a test
Inside any test, before the test body runs:
#![allow(unused)]
fn main() {
let _evlog = reticulum_std::test_support::event_log::init_event_log();
}
The handle is RAII: when the binding goes out of scope it removes
itself from the layer’s active-handles list. If the test thread
is panicking at drop-time, the buffer dumps to stderr with a
=== EVENT LOG DUMP … banner that cargo test surfaces in the
failure listing.
Use init_event_log_to_file(path) instead of init_event_log()
when the test wants to assert on the dumped content directly
(std::fs::read_to_string(path)).
To make the test fail loud on undocumented schema gaps, end the test body with:
#![allow(unused)]
fn main() {
reticulum_std::assert_no_schema_violations!(_evlog);
}
It panics if any EVENT_SCHEMA_VIOLATION line appears in the
buffer.
How to add an event
Two steps, both in the same commit:
-
Convert the call site. Replace the printf-style
tracing::debug!with structured fields:#![allow(unused)] fn main() { tracing::debug!( event = "FOO", iface = %iface_name, dst = %HexShort(&dst_hash), hops = packet.hops, len = bytes.len(), ); }%for Display,?for Debug. Values must be ASCII without whitespace,=, or non-printable characters — otherwise the field-value validator fires (see below). For Rust keywords liketype, use the raw identifierr#type. -
Add a catalogue entry in
reticulum-std/src/test_support/event_log.rs’sEVENT_CATALOG:#![allow(unused)] fn main() { EventSchema { name: "FOO", required_keys: &["iface", "dst", "hops", "len"], }, }required_keysshould list every field the call site sets. The subscriber checks that every catalogued event’s emission includes all required keys; missing keys produce aEVENT_SCHEMA_VIOLATIONline in the dumped buffer alongside the original event.
Catalogue entries without a live emitting site are explicitly discouraged: the runtime-validation layer can’t detect them, so they silently rot. Only add entries you have a corresponding emit for.
Validation behaviour
Two violation classes, both non-blocking — the original event line is never suppressed.
Schema violation (per-handle)
EVENT_SCHEMA_VIOLATION event=<NAME> missing=[a,b] caller=file:line t=<ms>
Emitted when a catalogued event misses required keys at emission.
Each active handle’s catalogue lookup chains the production
EVENT_CATALOG with the handle’s own extra_schemas, so
test-only schemas don’t pollute the production catalogue.
Field-value violation (per-event)
EVENT_FIELD_VIOLATION event=<NAME> field=<key> value_problem=<kind> caller=file:line t=<ms>
Emitted when a field’s stringified value contains ASCII
whitespace, =, or non-printable characters. Such values break
the whitespace-tokenised parser used by Stage-7’s
jl --filter <key>=<value> filter. <kind> is one of
whitespace, equals, non_printable.
The fix at the call site is to pick a value form that doesn’t
need escaping — substitute _ for spaces, drop = from value
strings, etc. The original event line is still emitted; the
tester sees the violation alongside, treats it as a bug.
Multi-process workflow
Spawned subprocesses (e.g. an lnsd child of an integration
test) emit to a per-process file when given two env vars:
LEVICULUM_EVENT_LOG=/tmp/leviculum-events-<pid>.log \
LEVICULUM_EVENT_NODE=node-a \
./lnsd ...
LEVICULUM_EVENT_LOG=<path>— child appends each event line (and any field-violations) to<path>as it emits. When unset, the subscriber writes only to the in-memory buffer used for panic-dump.LEVICULUM_EVENT_NODE=<name>— supplies thenode=value.
After the children exit, the parent merges all per-process files:
#![allow(unused)]
fn main() {
use reticulum_std::test_support::event_log::merge_event_logs;
let merged: Vec<String> = merge_event_logs(&[
PathBuf::from("/tmp/leviculum-events-12345.log"),
PathBuf::from("/tmp/leviculum-events-12346.log"),
]);
}
merge_event_logs reads every input, parses the trailing t=<n>
token of each line, and returns the union sorted by t (stable
on tie). Lines without parseable t= sort to the end with
their relative order preserved.
Per-process clock note: t= values are millisecond offsets from
each subscriber’s local init time, not a shared wall clock.
Merged ordering is monotone across the union but doesn’t directly
say which real-world event came first across hosts. For
wall-clock correlation add a per-emission timestamp field to the
catalogue (ts=<unix-ms>) and sort on that instead.
Production-daemon integration
lnsd honours both env vars at startup via
reticulum_std::test_support::event_log::install_global_subscriber().
When LEVICULUM_EVENT_LOG is unset, the install path is
functionally equivalent to the previous
tracing_subscriber::fmt().init() call — no event-log layer is
built, so runtime overhead is whatever the fmt layer would
otherwise impose.
rnsd is Python-side (vendor/Reticulum); structured event
capture for it is out of scope for the current Rust-side work.
See also
- Codeberg #39 piece 1 (this document’s spec).
reticulum-std/src/test_support/event_log.rs(implementation + catalogue).reticulum-std/src/test_support/tracing_setup.rs(Registry composition + Once-guard).reticulum-std/tests/event_log_subscriber.rs(unit tests).reticulum-std/tests/event_log_multiprocess.rs(multi-process merge integration test).- Stage 7:
jl/jldifffilter tools that consume this format.
jl and jldiff — filtering and comparing structured event logs
Stage 7 / Codeberg #39 piece 4. Two CLI tools that consume the
structured event-log format established by
structured-event-logs.md:
jl— filter and slice an event log. Reads from stdin or one or more files, applies AND-combined filters, emits matching lines unchanged.jldiff— compare two event logs by an alignment-key tuple. Partitions events into LEFT_ONLY / RIGHT_ONLY / MATCHED_DIFFER / MATCHED_IDENTICAL buckets.
Examples below are verified by
tests/jl_jldiff_docs.rs. If you change a worked example here, update the test; if a worked example breaks, the doc is wrong, not the test.
Test infrastructure
The tools are exercised by six test files in reticulum-std/tests/:
jl_filter.rs— Phase A unit/integration tests forjlin isolation.jldiff_compare.rs— Phase B unit/integration tests forjldiffin isolation.jl_jldiff_workflow.rs— end-to-end Subscriber → binary tests.jl_jldiff_fixtures.rs— checked-in real-shape log files driving expected outputs.jl_jldiff_edge_cases.rs— boundary and adversarial inputs.jl_jldiff_docs.rs— every example below is mirrored here as a test.
When a Stage-6 format change drifts these tools, all six of those files are likely to fail at once; that is the intended signal.
Format recap
EVENT_NAME node=<name> k1=v1 k2=v2 ... kN=vN t=<rel-ms>
EVENT_NAMEfirst.node=<name>always second when present.- Other fields alphabetically sorted.
t=<ms>always last; integer ms relative to subscriber init.
Lines that do not fit the structured shape (banners, free text,
cargo-test output) pass through jl unchanged. See
structured-event-logs.md for the
full format spec, the runtime catalogue, and the violation-line
synthesis rules.
jl — filter binary
jl [--filter <expr>]... [--node <name>] [--since-event <NAME>] [--until-event <NAME>] [INPUT...]
| Flag | Effect |
|---|---|
--filter <expr> | Filter expression. Repeatable; AND-combined. |
--node <name> | Shorthand for --filter node=<name>. |
--since-event <NAME> | Drop everything before the first event whose EVENT_NAME is <NAME>. The matching event is included. At most one. |
--until-event <NAME> | Drop everything at and after the first event whose EVENT_NAME is <NAME>. The matching event is excluded. At most one. |
INPUT... | Optional file paths. Without any, reads stdin. Multiple files are read in order; output preserves order. |
Filter expression forms:
| Form | Meaning |
|---|---|
key=value | exact match |
key=* | event has that key (any value) |
key=prefix* | value starts with prefix |
t<N, t>N, t<=N, t>=N | numeric t comparison |
The event key is special-cased: event=PKT_RX matches BOTH a
real PKT_RX ... line (where the EVENT_NAME first token is
PKT_RX) AND a synthetic violation line whose explicit event=
field is PKT_RX. This makes the filter consistent across real
events and the EVENT_SCHEMA_VIOLATION / EVENT_FIELD_VIOLATION
lines that reference them.
Example 1: filter to one event-name
Input:
PKT_RX node=alpha dst=abc1 hops=0 iface=lora0 len=64 type=Data t=10
ANN_RX node=alpha dst=abc1 hops=0 iface=lora0 path_response=false t=20
PATH_ADD node=alpha dst=abc1 hops=0 iface=lora0 next_hop=alpha ok=true source=announce table_len=1 t=21
PKT_RX node=alpha dst=abc2 hops=1 iface=lora0 len=64 type=Data t=80
Command:
jl --filter event=PKT_RX
Output:
PKT_RX node=alpha dst=abc1 hops=0 iface=lora0 len=64 type=Data t=10
PKT_RX node=alpha dst=abc2 hops=1 iface=lora0 len=64 type=Data t=80
Example 2: slice between two markers
Input:
PKT_LOCAL node=alpha dst=abc1 iface=lora0 matched=true t=10
PKT_LOCAL node=alpha dst=abc1 iface=lora0 matched=true t=20
PATH_ADD node=alpha dst=abc1 hops=0 iface=lora0 next_hop=alpha ok=true source=announce table_len=1 t=30
PKT_RX node=alpha dst=abc2 hops=0 iface=lora0 len=64 type=Data t=40
PKT_RX node=alpha dst=abc3 hops=0 iface=lora0 len=64 type=Data t=50
PKT_DROP node=alpha dst=abc4 hops=3 iface_in=lora0 reason=ttl_expired type=Data t=60
PKT_RX node=alpha dst=abc5 hops=0 iface=lora0 len=64 type=Data t=70
Command:
jl --since-event PATH_ADD --until-event PKT_DROP
Output:
PATH_ADD node=alpha dst=abc1 hops=0 iface=lora0 next_hop=alpha ok=true source=announce table_len=1 t=30
PKT_RX node=alpha dst=abc2 hops=0 iface=lora0 len=64 type=Data t=40
PKT_RX node=alpha dst=abc3 hops=0 iface=lora0 len=64 type=Data t=50
Example 3: time window
Input (same as Example 1), with one extra later event:
PKT_RX node=alpha dst=abc1 hops=0 iface=lora0 len=64 type=Data t=10
ANN_RX node=alpha dst=abc1 hops=0 iface=lora0 path_response=false t=20
PATH_ADD node=alpha dst=abc1 hops=0 iface=lora0 next_hop=alpha ok=true source=announce table_len=1 t=21
PKT_RX node=alpha dst=abc2 hops=1 iface=lora0 len=64 type=Data t=80
PKT_RX node=alpha dst=abc3 hops=2 iface=lora0 len=64 type=Data t=200
Command:
jl --filter t>=20 --filter t<100
Output:
ANN_RX node=alpha dst=abc1 hops=0 iface=lora0 path_response=false t=20
PATH_ADD node=alpha dst=abc1 hops=0 iface=lora0 next_hop=alpha ok=true source=announce table_len=1 t=21
PKT_RX node=alpha dst=abc2 hops=1 iface=lora0 len=64 type=Data t=80
jldiff — compare binary
jldiff --align-on <key>[,<key>...] LEFT_FILE RIGHT_FILE
The alignment-key tuple groups events on each side. Each group’s
events are paired by file order (1st left ↔ 1st right, …); surplus
events on either side go to LEFT_ONLY / RIGHT_ONLY. Events
missing one of the align-keys are unalignable and surface in the
appropriate _ONLY bucket with an [unalignable: missing key X]
annotation.
Output format:
=== LEFT_ONLY (N events) ===
<event line>
...
=== RIGHT_ONLY (N events) ===
<event line>
...
=== MATCHED_DIFFER (N pairs) ===
L: <left event line>
R: <right event line>
DIFF: key=lvalue|rvalue [key=lvalue|rvalue ...]
=== MATCHED_IDENTICAL (N pairs) ===
MATCHED_IDENTICAL is count-only — events with no field
differences are not re-listed. The t= field is reported in DIFF
lines when it differs (which is normal — alignment keys are how
you say “same logical event”; t shifts naturally between runs).
Example 4: compare two mvr-test runs
a.log:
PKT_RX node=alpha dst=abc1 hops=0 iface=lora0 len=64 type=Data t=10
PATH_ADD node=alpha dst=abc1 hops=0 iface=lora0 next_hop=alpha ok=true source=announce table_len=1 t=11
ANN_RX node=alpha dst=abc1 hops=0 iface=lora0 path_response=false t=20
b.log:
PKT_RX node=alpha dst=abc1 hops=0 iface=lora0 len=64 type=Data t=15
PATH_ADD node=alpha dst=abc1 hops=2 iface=lora0 next_hop=alpha ok=true source=announce table_len=1 t=16
Command:
jldiff --align-on event,dst a.log b.log
Output:
=== LEFT_ONLY (1) ===
ANN_RX node=alpha dst=abc1 hops=0 iface=lora0 path_response=false t=20
=== RIGHT_ONLY (0) ===
=== MATCHED_DIFFER (2 pairs) ===
L: PKT_RX node=alpha dst=abc1 hops=0 iface=lora0 len=64 type=Data t=10
R: PKT_RX node=alpha dst=abc1 hops=0 iface=lora0 len=64 type=Data t=15
DIFF: t=10|15
L: PATH_ADD node=alpha dst=abc1 hops=0 iface=lora0 next_hop=alpha ok=true source=announce table_len=1 t=11
R: PATH_ADD node=alpha dst=abc1 hops=2 iface=lora0 next_hop=alpha ok=true source=announce table_len=1 t=16
DIFF: hops=0|2 t=11|16
=== MATCHED_IDENTICAL (0 pairs) ===
Example 5: multi-key alignment (lnsd vs Python-RNS)
a.log (lnsd):
PKT_RX node=alpha dst=abc1 hops=0 iface=lora0 len=64 type=Data t=10
PKT_RX node=alpha dst=abc1 hops=0 iface=tcp1 len=64 type=Data t=20
b.log (Python-RNS):
PKT_RX node=alpha dst=abc1 hops=0 iface=lora0 len=64 type=Data t=12
PKT_RX node=alpha dst=abc1 hops=0 iface=tcp1 len=64 type=Data t=22
Command:
jldiff --align-on event,dst,iface a.log b.log
Output:
=== LEFT_ONLY (0) ===
=== RIGHT_ONLY (0) ===
=== MATCHED_DIFFER (2 pairs) ===
L: PKT_RX node=alpha dst=abc1 hops=0 iface=lora0 len=64 type=Data t=10
R: PKT_RX node=alpha dst=abc1 hops=0 iface=lora0 len=64 type=Data t=12
DIFF: t=10|12
L: PKT_RX node=alpha dst=abc1 hops=0 iface=tcp1 len=64 type=Data t=20
R: PKT_RX node=alpha dst=abc1 hops=0 iface=tcp1 len=64 type=Data t=22
DIFF: t=20|22
=== MATCHED_IDENTICAL (0 pairs) ===
The multi-key tuple (event, dst, iface) keeps the two interfaces
separate even though both have the same event and dst —
without iface in the key, jldiff would multi-occurrence-pair
them in file order, which is fine but obscures the per-interface
view.
Workflow notes
When an mvr-test fails, the dump goes to stderr framed by
=== EVENT LOG DUMP ... === banners. Pipe it through jl to
narrow:
just mvr 2>&1 | jl --filter event=PATH_ADD
The banners and any free-text lines around the dump pass through unchanged; only the structured events filter.
For an A/B comparison between two runs, capture each run’s output
to a file and run jldiff:
# Run a baseline; capture only the structured events.
just mvr 2>&1 | jl > baseline.log
# Run again after a change.
just mvr 2>&1 | jl > candidate.log
# Diff aligned on the event identity.
jldiff --align-on event,dst,iface baseline.log candidate.log
For multi-process logs, the Stage-6
merge_event_logs
helper produces a t-ordered union; jl and jldiff then operate
on the merged file as if it came from a single subscriber.
See also
structured-event-logs.md— Stage-6 format spec, subscriber architecture, runtime catalogue.- Codeberg #39 — the test framework epic this batch closes.
Storage Trait Split Analysis
Deep analysis of every Storage trait method: callers, frequency, embedded relevance, and proposed sub-trait groupings.
Method Inventory: 71 methods across 15 data categories
Group 1: Packet Dedup (3 methods)
| # | Method | Caller | Frequency | Embedded? |
|---|---|---|---|---|
| 1 | has_packet_hash | Transport (process_incoming) | hot – every inbound packet | ESSENTIAL |
| 2 | add_packet_hash | Transport (9 sites: send/receive/proof/data) | hot – every packet | ESSENTIAL |
| 3 | remove_packet_hash | DEAD CODE – 0 production calls | never | dead |
Embedded impl: Fixed-size ring buffer, e.g. [[u8; 32]; 512] with
write cursor. Has-check is linear scan (512 x 32B = 16KB). Cannot be
no-op – without dedup, packets loop forever.
Group 2: Path Table (12 methods)
| # | Method | Caller | Frequency | Embedded? |
|---|---|---|---|---|
| 4 | get_path | Transport (19 sites) | hot | ESSENTIAL |
| 5 | set_path | Transport (4 sites) | frequent | ESSENTIAL |
| 6 | remove_path | NodeCore (1), Transport (3), RPC (1) | sometimes | ESSENTIAL |
| 7 | path_count | NodeCore (1), Transport (1), Driver (1) | rarely | nice-to-have |
| 8 | expire_paths | Transport (clean_path_states) | periodic | ESSENTIAL |
| 9 | earliest_path_expiry | Transport (next_deadline) | periodic | ESSENTIAL |
| 10 | has_path | NodeCore (3), Transport (4), Driver (1) | frequent | ESSENTIAL |
| 11 | path_entries | Transport (2: path_table_entries, drop_all_paths_via) | rarely | nice-to-have |
| 12 | get_path_state | Transport (1: path_is_unresponsive) | sometimes | nice-to-have |
| 13 | set_path_state | Transport (3: mark_path_unresponsive/responsive) | sometimes | nice-to-have |
| 14 | clean_stale_path_metadata | Transport (clean_path_states) | periodic | nice-to-have |
| 15 | remove_paths_for_interface | NodeCore (1), Transport (1) | rarely | ESSENTIAL |
Embedded impl: Fixed-size array, e.g. [Option<PathEntry>; 32]
with LRU eviction on set. PathEntry is ~50 bytes, total ~1.6KB. Cannot
be no-op – node can’t route without paths.
Path state (methods 12-14) is separable – unresponsive tracking is a quality-of-life feature. An embedded node could skip it and just remove stale paths via expiry.
Group 3: Announce Processing (11 methods)
| # | Method | Caller | Frequency | Embedded? |
|---|---|---|---|---|
| 16 | get_announce | Transport (5 sites) | sometimes | ESSENTIAL |
| 17 | get_announce_mut | NodeCore (1), Transport (2) | sometimes | ESSENTIAL |
| 18 | set_announce | Transport (4 sites) | sometimes | ESSENTIAL |
| 19 | remove_announce | Transport (2: check_announce_rebroadcasts) | sometimes | ESSENTIAL |
| 20 | announce_keys | Transport (2: next_deadline, check_announce_rebroadcasts) | periodic | ESSENTIAL |
| 21 | get_announce_cache | NodeCore (1), Transport (3) | sometimes | ESSENTIAL |
| 22 | set_announce_cache | NodeCore (3), Transport (1) | sometimes | ESSENTIAL |
| 23 | clean_announce_cache | Transport (1: clean_path_states) | periodic | nice-to-have |
| 24 | get_announce_rate | Transport (1: check_announce_rate) | sometimes | OPTIONAL |
| 25 | set_announce_rate | Transport (3: check_announce_rate) | sometimes | OPTIONAL |
| 26 | announce_rate_entries | Transport (1: rate_table_entries) | rarely | OPTIONAL |
Embedded impl: AnnounceEntry array, e.g. [Option<AnnounceEntry>; 16] (~2KB). Announce cache stores raw bytes – variable size, harder
for embedded (up to ~500 bytes each, so 16 x 500 = ~8KB). Cannot be
no-op for core announces (16-22). Rate limiting (24-26) CAN be no-op –
node just doesn’t rate-limit.
Group 4: Path Requests (3 methods)
| # | Method | Caller | Frequency | Embedded? |
|---|---|---|---|---|
| 27 | get_path_request_time | Transport (2: send_path_request rate limiting) | sometimes | ESSENTIAL |
| 28 | set_path_request_time | Transport (1) | sometimes | ESSENTIAL |
| 29 | check_path_request_tag | Transport (1: handle_path_request dedup) | sometimes | ESSENTIAL |
Embedded impl: Small fixed array, e.g. [([u8; 16], u64); 16] for
request times (~384B), ring buffer for tags. Cannot be no-op – without
request dedup, path request storms occur.
Group 5: Receipts (5 methods)
| # | Method | Caller | Frequency | Embedded? |
|---|---|---|---|---|
| 30 | get_receipt | Transport (4: get_receipt, mark_delivered, proof handling) | sometimes | OPTIONAL |
| 31 | set_receipt | Transport (3: create_receipt, create_receipt_with_timeout, mark_delivered) | sometimes | OPTIONAL |
| 32 | remove_receipt | 0 production calls (only via expire_receipts) | never | dead (direct) |
| 33 | expire_receipts | Transport (1: check_receipt_timeouts) | periodic | OPTIONAL |
| 34 | earliest_receipt_deadline | Transport (1: next_deadline) | periodic | OPTIONAL |
Embedded impl: Fixed array, e.g. [Option<PacketReceipt>; 8]
(~1KB). CAN be no-op – node works without delivery proofs. Links still
establish; resources still transfer. You just don’t get explicit delivery
confirmation for single packets.
Group 6: Known Identities (2 methods)
| # | Method | Caller | Frequency | Embedded? |
|---|---|---|---|---|
| 35 | get_identity | NodeCore (1: send_single_packet), Driver (1) | sometimes | ESSENTIAL |
| 36 | set_identity | NodeCore (1: remember_identity) | sometimes | ESSENTIAL |
Embedded impl: Fixed array, e.g. [([u8; 16], Identity); 16].
Identity is ~128 bytes, total ~2.3KB. Borderline essential – without it,
node can’t encrypt to a destination whose announce it already saw but
isn’t currently cached in the announce table. Could be no-op if the node
only talks to destinations it just heard announce.
Group 7: Transport Relay (14 methods)
| # | Method | Caller | Frequency | Embedded? |
|---|---|---|---|---|
| 37 | get_link_entry | Transport (3: forward_link_routed, process_data, is_for_local_client_link) | sometimes | RELAY ONLY |
| 38 | get_link_entry_mut | Transport (1: mark link validated on proof) | rarely | RELAY ONLY |
| 39 | set_link_entry | Transport (1: handle_link_request – insert bidirectional route) | rarely | RELAY ONLY |
| 40 | remove_link_entry | 0 production calls (only via expire/cleanup) | never | dead (direct) |
| 41 | has_link_entry | Transport (3: dedup exemptions, is_link_routed check) | sometimes | RELAY ONLY |
| 42 | expire_link_entries | Transport (1: clean_link_table) | periodic | RELAY ONLY |
| 43 | earliest_link_deadline | Transport (1: next_deadline) | periodic | RELAY ONLY |
| 44 | remove_link_entries_for_interface | NodeCore (1), Transport (1) | rarely | RELAY ONLY |
| 45 | get_reverse | Transport (1: proof routing) | sometimes | RELAY ONLY |
| 46 | set_reverse | Transport (3: forward_packet, link-routed data, proof handling) | sometimes | RELAY ONLY |
| 47 | remove_reverse | Transport (1: proof routing) | sometimes | RELAY ONLY |
| 48 | has_reverse | 0 production calls (default impl, test only) | never | dead |
| 49 | expire_reverses | Transport (1: clean_reverse_table) | periodic | RELAY ONLY |
| 50 | remove_reverse_entries_for_interface | NodeCore (1), Transport (1) | rarely | RELAY ONLY |
Embedded impl: CAN be full no-op for leaf nodes
(enable_transport=false). A leaf node never relays, never builds
link/reverse tables. If an embedded node IS a relay, needs fixed arrays:
[Option<LinkEntry>; 16] (~1KB), [Option<ReverseEntry>; 32] (~2KB).
Group 8: Discovery Path Requests (5 methods)
| # | Method | Caller | Frequency | Embedded? |
|---|---|---|---|---|
| 51 | set_discovery_path_request | Transport (1: handle_path_request) | sometimes | RELAY ONLY |
| 52 | get_discovery_path_request | Transport (3: handle/retry/send_discovery) | sometimes | RELAY ONLY |
| 53 | remove_discovery_path_request | Transport (2: send_discovery_path_response) | sometimes | RELAY ONLY |
| 54 | expire_discovery_path_requests | Transport (1: clean_path_states) | periodic | RELAY ONLY |
| 55 | discovery_path_request_dest_hashes | Transport (2: next_deadline, retry) | periodic | RELAY ONLY |
Embedded impl: CAN be full no-op for leaf nodes. Only transport
nodes forward path requests on behalf of others. Leaf nodes send their
own path requests via send_path_request (Group 4), not this mechanism.
Group 9: Ratchets (7 methods)
| # | Method | Caller | Frequency | Embedded? |
|---|---|---|---|---|
| 56 | get_known_ratchet | Transport (1: set_local_client ratchet replay) | rarely | OPTIONAL |
| 57 | remember_known_ratchet | Transport (1: handle_announce), NodeCore (2: announce_destination, check_mgmt_announces) | rarely | OPTIONAL |
| 58 | has_known_ratchet | 0 production calls | never | dead |
| 59 | known_ratchet_count | 0 production calls (test only) | never | dead |
| 60 | expire_known_ratchets | Transport (1: clean_path_states) | periodic | OPTIONAL |
| 61 | store_dest_ratchet_keys | NodeCore (2: announce_destination, check_mgmt_announces) | rarely | OPTIONAL |
| 62 | load_dest_ratchet_keys | NodeCore (1: register_destination) | rarely | OPTIONAL |
Embedded impl: CAN be full no-op. Node works without forward secrecy – announces are still validated, links still established, data still encrypted. Ratchets add key rotation for post-compromise security. On a RAM-constrained device, this is the first thing to skip.
Group 10: Shared Instance (7 methods)
| # | Method | Caller | Frequency | Embedded? |
|---|---|---|---|---|
| 63 | add_local_client_dest | Transport (1: handle_announce for local client) | rarely | SHARED ONLY |
| 64 | remove_local_client_dests | Transport (1: set_local_client cleanup) | rarely | SHARED ONLY |
| 65 | has_local_client_dest | 0 production calls (test only) | never | dead |
| 66 | set_local_client_known_dest | Transport (1: handle_announce) | rarely | SHARED ONLY |
| 67 | has_local_client_known_dest | 0 production calls (test only) | never | dead |
| 68 | local_client_known_dest_hashes | Transport (2: set_local_client, clean_path_states) | rarely | SHARED ONLY |
| 69 | expire_local_client_known_dests | Transport (1: clean_path_states) | periodic | SHARED ONLY |
Embedded impl: Full no-op. Embedded nodes don’t share instances. Zero correctness impact. Shared instance is a desktop/server feature (multiple programs sharing one daemon via Unix sockets). An embedded node IS the daemon.
Group 11: Persistence & Diagnostics (2 methods)
| # | Method | Caller | Frequency | Embedded? |
|---|---|---|---|---|
| 70 | flush | Driver (2: save_persistent_state, auto_interface) | rarely | OPTIONAL |
| 71 | diagnostic_dump | Driver (1) | rarely | OPTIONAL |
Already have empty default implementations. No action needed.
Dead Code Summary
7 methods with zero production callers:
| Method | Notes |
|---|---|
remove_packet_hash | Defined but never called anywhere |
has_reverse | Only the default impl delegates to get_reverse; no external callers |
remove_link_entry | Only called indirectly via expire_link_entries |
remove_receipt | Only called indirectly via expire_receipts |
has_known_ratchet | Test-only |
known_ratchet_count | Test-only |
has_local_client_known_dest | Test-only (one NodeCore test) |
has_local_client_dest is also test-only in Transport tests.
Proposed Sub-Trait Split
Tier 1: CoreStorage – 28 methods
Every node needs these. Without them the protocol doesn’t function.
Packet dedup: has_packet_hash, add_packet_hash (2)
Path table: get_path, set_path, remove_path,
path_count, expire_paths,
earliest_path_expiry, has_path,
path_entries, remove_paths_for_interface (9)
Path state: get_path_state, set_path_state,
clean_stale_path_metadata (3)
Announces: get_announce, get_announce_mut,
set_announce, remove_announce,
announce_keys, get_announce_cache,
set_announce_cache, clean_announce_cache (8)
Path requests: get_path_request_time,
set_path_request_time,
check_path_request_tag (3)
Identities: get_identity, set_identity (2)
Persistence: flush (1)
Embedded minimum: ~30KB RAM total
| Collection | Layout | Size |
|---|---|---|
| Packet ring | [[u8; 32]; 512] | 16KB |
| Path table | [Option<PathEntry>; 32] | ~2KB |
| Announce table | [Option<AnnounceEntry>; 16] | ~1KB |
| Announce cache | [Option<([u8;16], Vec<u8>)>; 16] | ~8KB (variable, biggest concern) |
| Path requests | [([u8;16], u64); 16] | ~0.5KB |
| Path states | [Option<([u8;16], PathState)>; 32] | ~1KB |
| Identities | [Option<([u8;16], Identity)>; 16] | ~2KB |
Cannot be no-op.
Tier 2: ReceiptStorage – 5 methods
get_receipt, set_receipt, remove_receipt,
expire_receipts, earliest_receipt_deadline
Embedded: [Option<([u8;16], PacketReceipt)>; 8] ~1KB.
Can be no-op. Node works; single-packet delivery proofs are lost.
Links, resources, and channels all function – they have their own proof
mechanisms.
Why separate from CoreStorage: An nRF52840 sensor that only sends data and doesn’t care about delivery confirmation saves 1KB RAM and 5 method implementations.
Tier 3: TransportRelayStorage – 19 methods
Link table: get_link_entry, get_link_entry_mut,
set_link_entry, remove_link_entry,
has_link_entry, expire_link_entries,
earliest_link_deadline,
remove_link_entries_for_interface (8)
Reverse table: get_reverse, set_reverse, remove_reverse,
has_reverse, expire_reverses,
remove_reverse_entries_for_interface (6)
Discovery: set_discovery_path_request,
get_discovery_path_request,
remove_discovery_path_request,
expire_discovery_path_requests,
discovery_path_request_dest_hashes (5)
Embedded: Full no-op for leaf nodes. Only matters if
enable_transport=true.
Can be no-op. Leaf node can’t relay, but communicates fine as an
endpoint.
Why one trait instead of three: Link table, reverse table, and discovery requests are always used together – they’re all transport-mode infrastructure. A node is either a relay or it isn’t. There’s no use case for “relay with link table but no reverse table.”
Tier 4: RatchetStorage – 7 methods
get_known_ratchet, remember_known_ratchet,
has_known_ratchet, known_ratchet_count,
expire_known_ratchets,
store_dest_ratchet_keys, load_dest_ratchet_keys
Embedded: [Option<([u8;16], [u8;32], u64)>; 8] ~0.5KB if
implemented.
Can be full no-op. Forward secrecy is a security enhancement.
Without ratchets, announce encryption still works via the destination’s
static keys. An embedded sensor node may not need post-compromise key
rotation.
Why separate: Security feature with storage cost. Embedded devices with extreme RAM constraints can skip it. Also the only group that spans both Transport and NodeCore callers in a way that’s cleanly separable.
Tier 5: SharedInstanceStorage – 7 methods
add_local_client_dest, remove_local_client_dests,
has_local_client_dest,
set_local_client_known_dest, has_local_client_known_dest,
local_client_known_dest_hashes,
expire_local_client_known_dests
Embedded: Full no-op. Zero correctness impact. Shared instance is a desktop/server feature (multiple programs sharing one daemon via Unix sockets). An embedded node IS the daemon.
Why separate: Entirely irrelevant to embedded. Also the most likely candidate for removal from the trait hierarchy entirely – it could be a compile-time feature flag instead.
Tier 6: AnnounceRateStorage – 3 methods
get_announce_rate, set_announce_rate, announce_rate_entries
Embedded: [Option<([u8;16], AnnounceRateEntry)>; 16] ~0.5KB if
implemented.
Can be no-op. Without rate limiting, node processes all announces.
On a small network (typical for embedded LoRa), announce volume is low
enough that rate limiting is unnecessary.
Why separate from CoreStorage: Rate limiting is operator policy, not protocol correctness. A network of 5 LoRa nodes doesn’t need it.
Composition Design
#![allow(unused)]
fn main() {
// Tier 1 -- every node
trait CoreStorage { /* 28 methods */ }
// Tier 2-6 -- optional capabilities
trait ReceiptStorage { /* 5 methods */ }
trait TransportRelayStorage { /* 19 methods */ }
trait RatchetStorage { /* 7 methods */ }
trait SharedInstanceStorage { /* 7 methods */ }
trait AnnounceRateStorage { /* 3 methods */ }
// Backward-compatible supertrait -- existing code unchanged
trait Storage: CoreStorage + ReceiptStorage
+ TransportRelayStorage + RatchetStorage
+ SharedInstanceStorage + AnnounceRateStorage {}
// Blanket impl
impl<T> Storage for T where T: CoreStorage + ReceiptStorage
+ TransportRelayStorage + RatchetStorage
+ SharedInstanceStorage + AnnounceRateStorage {}
}
Transport<C, S> and NodeCore<R, C, S> keep S: Storage – zero
changes to existing code. MemoryStorage and FileStorage implement all
sub-traits and get Storage for free.
For embedded:
#![allow(unused)]
fn main() {
struct EmbeddedStorage {
// Only CoreStorage collections
// ~30KB RAM
}
impl CoreStorage for EmbeddedStorage { /* real impls */ }
impl ReceiptStorage for EmbeddedStorage { /* no-ops */ }
impl TransportRelayStorage for EmbeddedStorage { /* no-ops */ }
impl RatchetStorage for EmbeddedStorage { /* no-ops */ }
impl SharedInstanceStorage for EmbeddedStorage { /* no-ops */ }
impl AnnounceRateStorage for EmbeddedStorage { /* no-ops */ }
// Gets Storage automatically via blanket impl
}
Trade-offs & Uncertainties
Confident assessments
- Groups 5 (SharedInstance) and 3 (TransportRelay) are cleanly separable – no leaf-node code path touches them in production.
- Group 4 (Ratchets) is cleanly separable – all call sites have graceful None/no-op fallback.
- The 7 dead-code methods should be removed regardless of whether the trait is split.
Uncertainties
1. CoreStorage is still 28 methods. That’s a lot for a “minimal”
trait. I considered splitting Path and Announce into separate sub-traits,
but they’re called from the same Transport methods (handle_announce
touches both path table and announce table in the same function).
Splitting would require where S: PathStorage + AnnounceStorage bounds
scattered across Transport methods – high friction for zero embedded
benefit since both are essential.
2. Announce cache is the RAM wildcard. Each cached announce is up to ~500 bytes of raw wire data. 16 entries = 8KB. On an nRF52840 with 256KB RAM this is manageable, but on a smaller MCU it could dominate. The cache is needed for path responses and link requests from remote nodes. An endpoint that only initiates (never responds to path requests) could skip it – but that’s a very narrow use case.
3. Whether the split is worth the complexity. Right now, NoStorage
already serves as the “skip everything” option, and MemoryStorage with
capacity limits would cover the “real embedded” case. The sub-trait
split adds type-system guarantees but also adds 6 trait definitions, 6
impl blocks per storage type, and ongoing maintenance burden. If there’s
only one embedded target (nRF52840), a capacity-limited MemoryStorage
might be strictly better.
4. Conditional compilation is the pragmatic alternative. Instead of
sub-traits, use #[cfg(feature = "transport")] to gate
TransportRelay collections in MemoryStorage. Simpler, less
generic-parameter noise, but loses per-instance flexibility (can’t mix
endpoint and transport nodes in the same binary).
Recommendation
Remove the 7 dead methods now. Defer the sub-trait split until the first
embedded target actually needs it. The current MemoryStorage with
configurable capacity limits (already has packet_hash_cap,
identity_cap) extended to all collections covers the nRF52840 case
without any trait refactoring. The sub-trait design above is the right
split IF the refactor becomes necessary – but it’s a premature
abstraction today.
Broadcast behaviour: Python-RNS parity reference
This document is the source-of-truth reference that our Rust
reticulum-core broadcast code must match. It records what
Python-Reticulum does for every broadcast-related mechanism,
citing vendor/Reticulum/RNS/Transport.py (and neighbouring files)
by line. The companion mapping table at the end records the
Rust-side implementation or intentional divergence for each item.
The rule (Lew, 2026-04-15): Leviculum matches Python-RNS exactly for on-wire packet counts, packet types, and protocol semantics. Timing may diverge — jitter-window shape and interface pacing are free — as long as the counts and types stay identical.
1. Overview: what can appear on the wire
Python-Reticulum emits five distinct packet classes that can be broadcast or unicast:
| Class | Packet type | Scope | Who originates |
|---|---|---|---|
| Self-announce | Packet.ANNOUNCE | Broadcast | Destination.announce() |
| Forwarded announce | Packet.ANNOUNCE | Broadcast | Transport relay on received announce |
| Path-request | Packet.DATA with transport_type = BROADCAST | Broadcast | Transport.request_path() or client call |
| Path-response | Packet.ANNOUNCE with context = PATH_RESPONSE | Targeted | Transport answering a path-request |
| Link-request | Packet.LINKREQUEST | Unicast | Link.__init__ on initiator |
Everything below walks each class.
2. Self-originated announce
Trigger
Destination.announce(app_data, path_response=False, ...) at
vendor/Reticulum/RNS/Destination.py:243. Builds an announce
packet, calls announce_packet.send() once at line 322.
On-wire behaviour
Packet.send() at vendor/Reticulum/RNS/Packet.py:273-299 calls
Transport.outbound(self) exactly once and returns a receipt (or
False). There is no retry loop on the send path. A second
call on the same packet raises IOError (Packet.py guard).
Fan-out across interfaces
Inside Transport.outbound() at
vendor/Reticulum/RNS/Transport.py:1025-1167: for broadcast
packets (the “else” branch after the targeted-path and
transport-id branches), the code iterates Transport.interfaces
(line 1027) and transmits on each. There is no
if interface != packet.receiving_interface filter in the
announce path. For self-originated announces receiving_interface
is None anyway (the packet was created locally) so the question
is moot, but the point is relevant when we contrast with the
forwarded-announce path below.
Mode-based filtering is applied in this loop at lines 1040-1084
for MODE_ACCESS_POINT, MODE_ROAMING, MODE_BOUNDARY. These
modes suppress the rebroadcast on specific interfaces depending
on where the destination sits in the mesh. Bandwidth-cap logic
(lines 1089-1162) defers transmissions when the interface is
saturated.
Summary: Python self-announce = exactly 1 on-wire broadcast per
call, via one-shot Packet.send(). Count = 1.
3. Received-for-forwarding announce
Reception
Transport.inbound(data, interface) at Transport.py:1179+ is
the entry point for everything received on an interface. The
packet-hash dedup check at line 1227 is:
if not packet.packet_hash in Transport.packet_hashlist and
not packet.packet_hash in Transport.packet_hashlist_prev:
return True
Transport.packet_hashlist at Transport.py:99 is set().
Transport.packet_hashlist_prev at line 100 is the rolling
previous window used to keep the dedup memory constant-bounded.
A duplicate return here bails out of inbound() before any
announce-specific handling. This is the only mechanism that
prevents the same packet from being processed twice — critical
for the broadcast-back-to-source echo pattern that B1 relies on.
Insertion into announce_table
For announces (packet.packet_type == ANNOUNCE) that pass dedup,
the code path at Transport.py:1722-1764 initialises an
announce_table entry:
retries = 0 # line 1722
local_rebroadcasts = 0 # line 1724
block_rebroadcasts = False # line 1725
attached_interface = None # line 1726
retransmit_timeout = now + (RNS.rand() * PATHFINDER_RW) # line 1728
PATHFINDER_RW = 0.5 (seconds) at line 69, so the first
retransmission is scheduled within 0–500 ms of receipt.
Line 1748-1752 is the special case for announces that arrived from a local client (shared-instance peer over the local socket):
if Transport.from_local_client(packet):
retransmit_timeout = now
retries = Transport.PATHFINDER_R
This sets retries = 1 right away. Combined with the retry-loop
guard below, this makes local-client-sourced announces fire
only 1 time from the scheduler, not 2.
Retry loop
The periodic job at Transport.py:519-532 walks announce_table:
for destination_hash in Transport.announce_table:
announce_entry = Transport.announce_table[destination_hash]
if announce_entry[IDX_AT_RETRIES] > 0 and
announce_entry[IDX_AT_RETRIES] >= Transport.LOCAL_REBROADCASTS_MAX:
# "local rebroadcast limit reached"
completed_announces.append(destination_hash)
elif announce_entry[IDX_AT_RETRIES] > Transport.PATHFINDER_R:
# "retry limit reached"
completed_announces.append(destination_hash)
else:
if time.time() > announce_entry[IDX_AT_RTRNS_TMO]:
announce_entry[IDX_AT_RTRNS_TMO] =
time.time() + Transport.PATHFINDER_G + Transport.PATHFINDER_RW
announce_entry[IDX_AT_RETRIES] += 1
# ... build rebroadcast packet and send
With the constants:
| Constant | Value | Citation |
|---|---|---|
PATHFINDER_R | 1 | Transport.py:67 |
PATHFINDER_G | 5 s | Transport.py:68 |
PATHFINDER_RW | 0.5 s | Transport.py:69 |
LOCAL_REBROADCASTS_MAX | 2 | Transport.py:76 |
Deterministic walk — non-local-client source
Entry inserted with retries = 0, retransmit_at = now + rand*0.5s.
| Tick | retries in | Guard A | Guard B | Action | retries out |
|---|---|---|---|---|---|
| 1 | 0 | 0 > 0 && 0 >= 2 = false | 0 > 1 = false | fire, schedule next | 1 |
| 2 | 1 | 1 > 0 && 1 >= 2 = false | 1 > 1 = false | fire, schedule next | 2 |
| 3 | 2 | 2 > 0 && 2 >= 2 = true | — | remove | — |
Count = 2 rebroadcasts per received non-local-client announce.
Deterministic walk — local-client source
Entry inserted with retries = 1, retransmit_at = now.
| Tick | retries in | Guard A | Guard B | Action | retries out |
|---|---|---|---|---|---|
| 1 | 1 | 1 > 0 && 1 >= 2 = false | 1 > 1 = false | fire, schedule next | 2 |
| 2 | 2 | 2 > 0 && 2 >= 2 = true | — | remove | — |
Count = 1 rebroadcast per received local-client-sourced announce.
Immediate local-client forward
Lines 1788-1833: after the table insertion, Python also emits the announce immediately to every local-client interface that is not the receiving interface:
for local_interface in Transport.local_client_interfaces:
if packet.receiving_interface != local_interface:
new_announce = RNS.Packet(...)
new_announce.send()
This is the only place in the announce path where receiving_interface
filtering happens. It only applies to local-client interfaces — the
fanout onto LoRa, TCP, UDP interfaces is unfiltered. This confirms
that for the mixed LoRa-Serial + LoRa-RF topology our tests care
about, Python does not skip the received interface when
rebroadcasting.
Fan-out per rebroadcast fire
Each fire builds a new announce packet (lines 540-561), calls
send() → Transport.outbound(), which applies the mode
filtering and bandwidth-cap logic. The receiving interface is
implicitly included in the for interface in Transport.interfaces
loop (no exclusion check). Echoes are absorbed by the
packet_hashlist check at line 1227 when they arrive back.
Block-rebroadcasts path
announce_entry[IDX_AT_BLCK_RBRD] set to True (indices at
line 557 of the retry loop) reroutes the rebroadcast as a
PATH_RESPONSE packet (announce_context = PATH_RESPONSE,
line 537). This is how path-responses ride the same scheduler.
4. Path-request
Trigger
Transport.request_path(destination_hash, ...) at
Transport.py:2541 is the main producer. Clients call into it
via Destination.request_path() or explicit transport calls.
On-wire behaviour
At line 2561-2587: builds a Packet with
packet_type = Packet.DATA and
transport_type = Transport.BROADCAST, then calls
packet.send() once. Same one-shot pattern as self-announce.
Fan-out goes through the same Transport.outbound() broadcast
loop at Transport.py:1025-1167.
Count = 1 on-wire broadcast per path-request call. No retries in the scheduler for path-requests.
Rate-limiting
Path-requests are subject to PATH_REQUEST_MI = 20 seconds
minimum interval per destination (Transport.py:81) — clients
requesting the same path more often are throttled upstream of
Transport.outbound().
5. Path-response
Trigger
Two paths produce a PATH_RESPONSE:
- Active answer: Transport receives a path-request, has the
path, calls
Destination.announce(path_response=True, tag=...)with the matching identity. This produces aPacket.ANNOUNCEwithcontext = PATH_RESPONSE(Destination.py:309-310, 319-322) and sends it once. - Rebroadcast with block_rebroadcasts: the retry loop at
Transport.py:519-540emits path-responses whenannounce_entry[IDX_AT_BLCK_RBRD]is set. Same 2-fire count as a regular received-announce rebroadcast.
On-wire semantics
Path-responses are a packet-type subset of announces. The
fan-out logic is the same as announces. Consumers distinguish
by packet.context == PATH_RESPONSE.
Special routing
In Transport.outbound() at lines 1167+ (targeted-transport
branch), a packet with transport_id set AND a known next-hop
in path_table is routed to a single specific interface via
SendPacket, not broadcast. This is what happens when a
path-response is specifically addressed to the path-requester
rather than broadcast. In our Rust code this corresponds to
the target_interface: Some(idx) branch at transport.rs:4055-4070.
6. Link-request
Trigger
Link.__init__(destination=...) on the initiator. Internally
calls Packet(destination, link_data, Packet.LINKREQUEST, ...)
and sends it.
On-wire behaviour
Packet.LINKREQUEST (Packet.py:62) is unicast, not
broadcast. At Transport.py:1938: local-destination link
requests are dispatched to the destination’s attached interface
directly. Non-local paths route through next-hop. There is no
broadcast fanout.
Count = 1 unicast packet per link initiation. Not relevant to broadcast parity directly, but enumerated here for completeness.
7. Dedup (packet_hashlist)
| Item | Value | Citation |
|---|---|---|
| Storage | set() | Transport.py:99 |
| Previous-window storage | set() | Transport.py:100 |
| Max size | 1 000 000 entries | Transport.py:145 |
| Check site | line 1227 | Transport.py |
| Rotation | half-cleared when reaches hashlist_maxsize/2 | approximate, see cull job |
The dedup check is the only mechanism that prevents the self-heard echo when we (Rust) stop excluding the receiving interface from the rebroadcast fanout. Verifying the check fires reliably is a hard requirement for B1.
8. ANNOUNCE_CAP — per-interface rate limiter
Constants
| Constant | Value | Citation |
|---|---|---|
Reticulum.ANNOUNCE_CAP | 2 (percent of bandwidth) | Reticulum.py:116 |
Interface instances set
interface.announce_cap = Reticulum.ANNOUNCE_CAP/100.0 = 0.02
at Reticulum.py:731. Each interface also has
interface.bitrate (bps).
Logic
The rate limiter is consulted only for forwarded announces
(packet.hops > 0). Self-originated announces bypass it because
they only fire once and are not worth deferring.
At Transport.py:1091-1161:
if (packet.hops > 0):
if not hasattr(interface, "announce_cap"): ...
if not hasattr(interface, "announce_allowed_at"):
interface.announce_allowed_at = 0
if time.time() >= interface.announce_allowed_at and interface.bitrate:
tx_time = len(packet.raw) * 8 / interface.bitrate
wait_time = tx_time / interface.announce_cap
interface.announce_allowed_at = time.time() + wait_time
# proceed with immediate TX
else:
# queue for later
if not len(interface.announce_queue) >= Reticulum.MAX_QUEUED_ANNOUNCES:
interface.announce_queue.append(packet)
wait_time = tx_time / 0.02 = 50 × tx_time: each forwarded
announce “books” 50× its own airtime on the interface before the
next forwarded announce is allowed immediate TX.
Queue drain
When announce_allowed_at rolls past and there are queued
announces, the interface’s process_announce_queue() pops the
next one and emits it. This is a per-interface deferred-send
mechanism, not a transport-wide one.
9. LOCAL_REBROADCASTS_MAX
Covered in section 3 (retry loop). The enforcement sites are:
Transport.py:523: retry-loop guard A. Prevents emission whenretries >= LOCAL_REBROADCASTS_MAX.Transport.py:1588: secondary site that removes an entry fromannounce_tablewhen a duplicate announce arrives and the local rebroadcast counter has saturated. This is the “I’m hearing too many copies of this announce from others, stop my own rebroadcast too” path.
10. Management announce keepalive
Constants
| Constant | Value | Citation |
|---|---|---|
mgmt_announce_interval | 7 200 s (2 h) | Transport.py:162 |
| Initial-fire trick | last_mgmt_announce = now - interval + 15 | Transport.py:247 |
Behaviour
Transport.py:247 runs at startup and sets last_mgmt_announce
to 15 seconds ago minus the full interval, so the next check at
Transport.py:835 fires ~15 s after startup. Each fire walks
Transport.mgmt_destinations (a list of transport-control
destinations like probe responders and blackhole destinations,
populated at lines 220-241, 367 during Transport.start()) and
announces each.
After each successful batch the code updates
Transport.last_mgmt_announce = time.time().
Purpose
Without this keepalive, a node that loses its initial one-shot
Destination.announce() is unreachable until the next manual
announce. The 2-h re-announce gives the mesh a periodic refresh
without flooding the network with announce traffic.
11. Interface modes
Python-Reticulum distinguishes five interface modes
(Interfaces/Interface.py:45-50):
| Mode | Constant | Intent |
|---|---|---|
MODE_FULL | 0x01 | Default. Fully participating transport node. |
MODE_POINT_TO_POINT | 0x02 | Directed link, no announce flooding. |
MODE_ACCESS_POINT | 0x03 | Gateway to clients. Special path expiry. |
MODE_ROAMING | 0x04 | Mobile node. Selective rebroadcast. |
MODE_BOUNDARY | 0x05 | Edge between mesh segments. Selective rebroadcast. |
MODE_GATEWAY | 0x06 | Inter-mesh gateway. |
These are consulted in Transport.outbound() at lines 1040-1084
to suppress rebroadcast on specific interfaces.
block_rebroadcasts at the announce-table entry level is a
related per-entry flag.
Leviculum does not implement interface modes. All interfaces
behave as MODE_FULL. This is a documented divergence that Phase
A audit records; if a future scenario surfaces that requires
mode behaviour, a separate task lands them. Until then, our
fanout is “unfiltered over the broadcast-capable interface set”,
which is behaviourally equivalent to Python with all interfaces
in MODE_FULL.
12. Rust ↔ Python parity matrix
Legend: ✓ matches, ≈ matches in count/semantics with timing or structural divergence, ⚠ gap not yet addressed, ✗ does not match.
| Mechanism | Python reference | Rust today | Status | Notes |
|---|---|---|---|---|
| Self-announce one-shot | Destination.py:322, Packet.py:294 | transport.rs:1256-1280 schedules 3 retries beyond the initial | ✗ | Fixed in B3 |
| Self-announce on-wire count | 1 | 4 (1 + 3 retries) | ✗ | B3 brings to 1 |
| Self-announce fanout | all interfaces (MODE_FULL assumed) | send_on_all_interfaces(exclude=None) | ✓ | transport.rs:1243-1255 |
| Received-announce rebroadcast count | 2 (non-local-client), 1 (local-client) | 4 at retries=1 init + PATHFINDER_RETRIES=3 | ✗ | B2 brings to 2 |
| Received-announce fanout | all interfaces; echo dedup’d on RX | send_on_all_interfaces (no exclude) | ✓ | Matches Python. B1 verified by test_announces_forwarded_through_transport. |
| Packet-hash dedup on RX | Transport.py:1227 | transport.rs:1179 | ✓ | Identical semantics, rolling window |
PATHFINDER_G grace | 5 s | 5 000 ms | ✓ | constants.rs:117 |
PATHFINDER_RW jitter | 0.5 s | 500 ms (+ optional airtime factor) | ≈ | Option α permitted timing divergence |
LOCAL_REBROADCASTS_MAX | 2 | 2 | ✓ | constants.rs:133; enforcement at transport.rs:3945 |
ANNOUNCE_CAP | 2 % | 2 % | ✓ | constants.rs:246; impl at transport.rs:287-296, 4125 |
announce_queue / deferred-send | interface.announce_queue | InterfaceAnnounceCap.queue | ✓ | Same intent, Rust-side uses Vec |
mgmt_announce_interval | 7 200 s | 7 200 000 ms | ✓ | constants.rs:148; node/mod.rs:988-1048 |
| mgmt-announce initial 15 s trick | Transport.py:247 | node/mod.rs:75 + constant | ✓ | Verified by B4 audit |
| mgmt-announce iterates all dests | Python walks mgmt_destinations | check_mgmt_announces walks mgmt_destinations | ✓ | Verified by B4 audit |
| Path-request one-shot broadcast | Transport.py:2541-2587 | transport.rs (to verify in B7) | ≈ | B7 audit |
| Path-response targeted | transport.rs:4055-4070 | same mechanism | ✓ | Preserved |
| Interface modes (FULL/ROAMING/…) | 5 modes | none (all = FULL) | ⚠ | Documented gap; separate task |
block_rebroadcasts | per-entry flag | AnnounceEntry.block_rebroadcasts | ✓ | Verified by B7 audit |
13. Phase A resolutions of semantic ambiguities
B2 retry-count alignment
Question: PATHFINDER_R = 1 — does this mean 1 retry after
the initial or 1 TX total?
Resolution (walking the Python loop, section 3): Python fires
2 times per received non-local-client announce, bounded by
LOCAL_REBROADCASTS_MAX = 2 not by PATHFINDER_R. The
PATHFINDER_R guard (retries > PATHFINDER_R) would fire at
retries = 2 but LOCAL_REBROADCASTS_MAX fires first at
retries >= 2. In other words, for the default constants the
PATHFINDER_R guard is redundant with LOCAL_REBROADCASTS_MAX
in the non-local-client path.
Rust equivalent target: 2 fires per received non-local-client announce. Achievable in two ways:
- A. Set
PATHFINDER_RETRIES = 1and change the entry-insert attransport.rs:1973-1974fromretries: 1toretries: 0. Guards attransport.rs:3944-3945already readretries > PATHFINDER_RETRIESandlocal_rebroadcasts >= LOCAL_REBROADCASTS_MAX; both fire at the right count. - B. Set
PATHFINDER_RETRIES = 2and leave insert atretries: 1. Same on-wire count.
B2 commits path A — it more closely mirrors Python’s constants and counter semantics, so future upstream-audit readers see 1:1 constants.
B1 fanout alignment
Question: if we remove exclude_iface, can dedup reliably
catch the self-echo, and does it play well with Python peers?
Resolution: yes. Outgoing broadcasts go through
send_on_all_interfaces at transport.rs:1243-1255 which calls
self.storage.add_packet_hash() before emitting the
Action::Broadcast. The dedup check at transport.rs:1179
in process_incoming reads that set. The only edge case is the
dedup window rollover at HASHLIST_MAXSIZE = 1 000 000 entries —
a packet that is ~1M packets old could theoretically come back.
Not a concern in practice for single-day bench runs.
Python interop subtlety (discovered 2026-04-15 when the B1
change was first landed, caused a 3-node TCP relay test to fail,
then resolved by spacing out the test’s announce emissions): the
Python reference has a per-interface ingress control at
vendor/Reticulum/RNS/Interfaces/Interface.py:117-138. When two
announces arrive on the same interface faster than
IC_BURST_FREQ_NEW = 3.5/s (≈ 285 ms apart), Python activates
burst mode for at least IC_BURST_HOLD = 60 s then penalises for
IC_BURST_PENALTY = 300 s. Held announces are released by
process_held_announces every interface_jobs_interval = 5 s,
but only once the cooldown expires.
In a LoRa topology the multi-second airtime per transmit naturally spaces announces below this threshold, so ingress control never activates. In a TCP relay topology a Rust node that receives announces from both peers in rapid succession — and with B1 fans them both out on every interface, with only the retry scheduler’s 0-500 ms jitter spacing them — can trip Python’s ingress control on the receiving side.
This is not a Rust bug; it is Python’s intended rate-limit
behaviour that naive TCP-only tests can expose. The regression
guard test test_announces_forwarded_through_transport spaces
its two announce_destination calls by two seconds to keep
the spawned-peer interface’s ia_freq below 3.5 /s. Production
scenarios where two daemons announce in tight succession through
a Rust relay remain subject to Python’s ingress limits — exactly
as they would be through a Python relay.
Mode-less Rust
Decision: Leviculum continues without interface modes.
Documented as a deliberate scope reduction. Our scenarios and the
Python peer we interop against all use MODE_FULL implicitly.
A future Bug that requires MODE_ROAMING or similar gets its
own task; this parity doc predates and outscopes that work.
14. Usage
This document is the audit target for both sides:
- When we upgrade the vendored
RNS/tree to a new upstream release, the Python line numbers here are the first thing to re-verify. A changed line number is a hint the behaviour may have shifted; a changed mechanism is a new parity task. - When we add a new broadcast code path to
reticulum-core, we extend the parity matrix (section 12) and add a test underreticulum-std/tests/rnsd_interop/that verifies the new path matches what a live Python peer sees.
The parity matrix is the contract. Everything else in this document is the reading behind the entries.
Testing — Developer Quick Reference
One-page orientation. See CI Pipeline for the automation details.
TL;DR
- Writing code: run
cargo test -p <crate-you-touched>as you go. - Committing: nothing manual. Tier 1 runs in the background.
- Pushing: Tier 0 runs automatically and blocks on fail.
- Daily: Tier 2 (12:30, 18:30) and Tier 3 (02:00) run via systemd.
- Suite overview:
just status.
The four tiers
| Tier | When | Command | Time | Scope |
|---|---|---|---|---|
| 0 | on git push (hook) | just fast | ~3 min | fmt + clippy + workspace lib tests |
| 1 | after git commit (hook, background) | just standard | ~15 min (40 min cold1) | Tier 0 + core/tests + ffi + proxy + rnsd_interop |
| 2 | 12:30 & 18:30 daily (systemd timer) | just extensive | 30–90 min | Tier 1 + Docker integ suite |
| 3 | 02:00 daily (systemd timer) | just nightly | 2–6 h | Tier 2 + LoRa hardware tests |
Each tier includes every lower tier, so a green nightly proves the whole stack.
Results go to ~/.local/state/leviculum-ci/last-results.txt:
GREEN = passed, RED = failed (sticky notify-send alarm),
SKIPPED = deferred because another test held the lock (see
“Concurrent runs” below).
While writing code
Fast feedback. Run only what you changed:
cargo test -p reticulum-core --lib # touched core lib code
cargo test -p reticulum-std # touched std
cargo clippy -p reticulum-core # clippy for one crate
cargo fmt # apply formatter (not --check)
End of a work session with unsynced work? Don’t wait for the post-commit hook:
just standard # manually trigger Tier 1 (~15 min)
This is the “15-minute-budget” check that CLAUDE.md expects after every task.
Never run the full integ suite casually:
# DON'T do this without a reason — Docker tests collide with anything
# else using integ infra on the box.
cargo test -p reticulum-integ
If you must, use just extensive which is lock-protected and
serialised properly.
Before pushing
Nothing to type. git push triggers .githooks/pre-push which
runs just fast (Tier 0). A red Tier 0 aborts the push — fix, stage,
and push again.
Also checked: Tier 2 staleness.
- WARN at 5 commits or 8 h since last GREEN Tier 2 — push goes through with a visible warning, no action required.
- BLOCK at 10 commits or 24 h — push aborted. Run
just extensiveonce to clear, orgit push --no-verifyto override.
After committing
.githooks/post-commit fires scripts/run-tier1.sh in the
background. You’ll get a desktop notification (~15 min later) with
GREEN or a critical RED + log path. Multiple commits in a row
coalesce — no parallel re-runs.
Checking state
just status # last result per tier
just logs # tail most recent Tier 1 log
cat ~/.local/state/leviculum-ci/last-results.txt # full history
LoRa hardware tests
Tier 3 only. Requires two Heltec T114 boards + two RNode radios connected via USB. Manual runs:
just flash # flashes ALL attached T114s; touch-free
# since the Bug #13 firmware change.
# Double-tap RESET only if the runner
# prompts you (crashed-firmware fallback).
just flash-one /dev/ttyACM3 # flash one specific T114 (A/B testing)
just nightly # full Tier 3 run
A single LoRa test in isolation:
cargo test --release -p reticulum-integ --test-threads=1 -- \
--exact executor::tests::<name> --ignored --nocapture
All hardware tests are gated behind #[ignore]; --ignored
unlocks them. #[ignore] is reserved for hardware tests — don’t
use it for slow-but-CPU-only tests (wrong tier).
Concurrent runs
Only one integ test process can run at a time — Docker names and USB handles would otherwise collide. A second invocation exits in under a second:
[leviculum] Another integration test is already running.
[leviculum] Current holder:
[leviculum] pid=12345
[leviculum] started=2026-04-14T02:01:33
[leviculum] pkg=reticulum-integ
[leviculum] binary=reticulum_integ-abc123def
[leviculum] cwd=/path/to/leviculum
[leviculum] Wait for it to finish or stop that process, then retry.
A scheduled Tier 2 / Tier 3 that hits this case logs SKIPPED,
not RED, and sends a normal (not critical) notification. No
action needed — the next scheduled slot runs normally. In
practice this means: if you’re doing late-night hardware work and
the 02:00 nightly fires, it silently defers. You don’t need to
stop it.
Unit tests in other crates (reticulum-core, reticulum-std,
reticulum-ffi, reticulum-proxy, reticulum-cli) run in
parallel with a held integ lock — they never touch the integ
infrastructure.
Installing / updating the CI
just install-ci
Idempotent. Installs git hooks, systemd user units, state dirs, separate cargo target dir. Safe to re-run after pulling.
Golden rules
- Tests are never flaky. A failure is a real bug — diagnose and fix at the root, don’t retry until green.
- Don’t commit while tests are red.
#[ignore]is only for hardware-dependent tests. For CPU-expensive non-hardware tests, use a Cargo feature flag.
-
The CI uses its own
CARGO_TARGET_DIRat~/.cache/leviculum-ci-targetso IDE builds and CI builds don’t fight over the same incremental cache. The first Tier 1 run afterinstall-ci.shcompiles the workspace from scratch (~40 min); subsequent runs are incremental (~15 min). ↩
CI Pipeline
A self-hosted CI pipeline runs entirely on the developer’s machine.
Four tiers with different time budgets and triggers automate the
test discipline mandated by CLAUDE.md — no GitHub Actions, no
external runners.
Tiers
| Tier | Name | Trigger | Budget | Test scope |
|---|---|---|---|---|
| 0 | fast | pre-push hook | ~3 min | fmt + clippy (host + nrf firmware workspace, both BSPs) + rustdoc gate + workspace lib tests |
| 1 | standard | post-commit (background) | ~15 min (first run: 20-40 min cold compile) | Tier 0 + core/tests + ffi + proxy + rnsd_interop |
| 2 | extensive | on demand: systemctl --user start leviculum-ci-tier2.service | ~30-90 min | Tier 1 + integ Docker tests |
| 3 | nightly | systemd timer 02:00 daily | ~2-6h | Tier 2 + LoRa hardware tests |
Each tier runs everything from the lower tiers as well, so a green nightly proves the entire stack.
Installation
One command, idempotent:
just install-ci
It installs git hooks (via core.hooksPath = .githooks), runner
scripts, systemd user units, the separate cargo target dir, and the
state dir. Re-running is safe.
The installer detects the worktree it was run from and patches the
systemd-unit ExecStart paths to match — so a git worktree-based
second checkout (see “VM-mode install” below) installs its own units
that fire against itself.
VM-mode install (CI worktree on a long-running host)
For schneckenschreck or any other dedicated CI machine where the
nightly Tier-3 runs land, install with --vm-mode:
git worktree add ~/coding/libreticulum-ci master
cd ~/coding/libreticulum-ci
bash scripts/install-ci.sh --vm-mode
--vm-mode differs from the default install in two ways:
- The git-hook wiring (
core.hooksPath = .githooks) is skipped. The VM never commits or pushes; hooks would never fire. - A worktree-scoped marker file
(
.git/worktrees/<name>/leviculum-ci-vm-mode-marker) is created.run-tier2.shandrun-tier3-hw.shcheck this marker at the head of every run and, if present, invoke_repo-sync.shto dogit fetch + git checkout --force origin/master + git submodule update --recursive.
The marker is per-worktree, not per-user: a manual invocation of
run-tier2.sh from the developer’s primary checkout will not
trigger the destructive --force checkout against the wrong tree.
The synced commit hash is appended to last-results.txt as
<timestamp> tier2 sync HEAD=<short-hash> (or tier3-hw for the
nightly), so you can correlate scheduled runs with the master commit
they tested.
Manual operation
just fast # Tier 0
just standard # Tier 1
just extensive # Tier 2
just nightly # Tier 3
just status # show recent runs across all tiers
First-run expectation
Tier 1 runs in a separate CARGO_TARGET_DIR (~/.cache/leviculum-ci- target/) so it doesn’t fight your IDE’s target/ for inkremental
caches. The first run after install-ci.sh compiles the whole
workspace and all test binaries from scratch — plan for 20-40
minutes. Subsequent runs are incremental, ~5-15 minutes.
Notifications
notify-send is called on every Tier 1/2/3 result. Failures use
-u critical (sticky until dismissed); successes use -u low.
Prerequisite: notify-send needs DBUS_SESSION_BUS_ADDRESS and
XDG_RUNTIME_DIR in the user systemd manager environment, which
exists only when you have a logged-in graphical session. On a
headless server, notifications are silently dropped — inspect
~/.local/state/leviculum-ci/last-results.txt instead.
Stale-block on push
pre-push blocks the push if Tier 2 hasn’t run successfully in
≥ 10 commits or ≥ 24 hours (warning at 5 commits / 8h). To override:
git push --no-verify
To clear the block normally, run just extensive once (or wait for
the next scheduled run).
Logs
Location: ~/.local/state/leviculum-ci/
| File | Contents |
|---|---|
last-results.txt | one-line tally per run (<iso-timestamp> <tier> GREEN/RED <log-path>) |
tier1-YYYYMMDD-HHMMSS-PID.log | full Tier 1 output (one file per run) |
tier2-YYYYMMDD-HHMMSS-PID.log | full Tier 2 output |
nightly-YYYYMMDD-HHMMSS-PID.log | full Tier 3 output |
tier1.lock | flock for Tier 1 concurrency control |
tier1.dirty | marker that Tier 1 needs to (re-)run |
Rotation: tier 1/2 logs are deleted after 14 days; nightly logs after 60 days. Done at the start of each runner script.
Each script run gets its own log file (timestamp + PID suffix). No
run ever overwrites another run’s log — this is intentional so a
failure trace cannot vanish under a successful re-run. The path of
the specific log goes into last-results.txt so just status can
point at exactly the right file.
Convention: #[ignore] is for hardware-dependent tests only
In reticulum-integ, the CI tier separation depends on #[ignore]:
- Tier 2 runs
cargo testwith default behavior (skips ignored). - Tier 3 adds
--include-ignoredto pick up exactly the LoRa hardware tests.
If you mark a slow but non-hardware test as #[ignore], it ends up
in nightly Tier 3 alongside the LoRa tests — wrong tier. Use a
Cargo feature (e.g. slow-tests) for that case instead. Currently
the invariant #[ignore] tests == #[serial(lora)] tests holds; keep
it that way.
Concurrent test protection
Two cargo test -p reticulum-integ invocations on the same machine
fight over Docker container names and USB serial handles. To prevent
that, every integ test silently acquires a process-wide file lock on
~/.local/state/leviculum-ci/test.lock as the first step inside
TestRunner::new().
Single invocation: transparent. No extra output.
Two simultaneous invocations: the second exits within a second with
a multi-line [leviculum] message naming the current holder —
pid, started time, cwd, optionally the test-name filter. Example:
[leviculum] Another integration test is already running.
[leviculum] Current holder:
[leviculum] pid=12345
[leviculum] started=2026-04-14T02:01:33
[leviculum] pkg=reticulum-integ
[leviculum] binary=reticulum_integ-abc123def
[leviculum] cwd=/path/to/leviculum
[leviculum] Wait for it to finish or stop that process, then retry.
On-demand Tier 2 / scheduled Tier 3 runs that collide with a manual test
drop a marker file at ~/.local/state/leviculum-ci/lock-contention;
the runner scripts observe the marker, classify the run as SKIPPED
(not RED), send a normal (not critical) notification, and delete
the marker. No false-alarm pages.
Inspecting the lock
cat ~/.local/state/leviculum-ci/test.lock # current (or last) holder
ls ~/.local/state/leviculum-ci/lock-contention # marker if present
Force-release
Not applicable. The kernel releases the flock the moment the holding
process closes its fd — on clean exit, panic, SIGINT, SIGKILL, and
even host reboot. There is no TTL, no heartbeat, no manual cleanup
path. A stale test.lock file on disk after a reboot is self-
healing: the next invocation opens it, flock succeeds immediately
(kernel state is empty post-reboot), and the stale content is
overwritten.
Scope
The lock protects only reticulum-integ tests. Unit tests in
reticulum-core, reticulum-std, reticulum-ffi,
reticulum-proxy, and reticulum-cli do not acquire it — they
parallelise freely with an in-progress integ run. Pure-parse unit
tests inside reticulum-integ (e.g. compose YAML validation,
radio-config wire round-trips) also don’t acquire the lock because
they never call TestRunner::new().
Filesystem requirement
Local filesystem only. flock semantics over NFS / sshfs are
implementation-defined. If your $HOME is on a network filesystem,
the lock behaviour is not guaranteed. This is a single-developer
dev-box tool; not an issue in practice.
Hardware test profiles (Tier 3)
Tier 3 (just nightly) runs LoRa hardware tests over USB-attached
embedded devices. Different tests need different subsets of the
attached hardware powered on; the rest must stay off so that
their RF activity does not contaminate the run.
The mapping of devices to USB-hub ports lives at
reticulum-integ/profiles/devices.toml. Each LoRa test descriptor
in reticulum-integ/tests/lora_*.toml may carry a
profile = "..." field that names one of the profiles defined in
devices.toml. Tests without that field default to the default
profile (= every device powered on).
Active profile assignments on master HEAD:
| Test descriptor | Profile | Active devices |
|---|---|---|
lora_lncp_bidir.toml | lora_lncp_bidir | t-beam-1, t-beam-2 |
lora_lnode_lncp_bidir.toml | lora_lnode_lncp_bidir | pocket-v2, t114 |
(all other lora_*.toml) | default (implicit) | all five devices |
Adding a new profile means: (a) declare the device subset in
devices.toml, (b) add profile = "<name>" to the test descriptors
that need it. No code changes required.
The default fallback is intentional: a test without an explicit
profile keeps the historic behaviour of “all devices powered on,
nothing power-managed”, so existing tests are not at risk of
silent regression from the introduction of the profile system.
USB-hub power switching is performed by the hamster-side helper
script (scripts/usbhub-helper) over a restricted SSH key from
schneckenschreck. See scripts/run-tier3-hw.sh for the
orchestration logic.
libvirt USB-passthrough caveat
When a device is disabled via the helper, hub power is genuinely
cut on hamster — no power, no LoRa TX/RX, no MCU activity. But
the schneckenschreck VM keeps the cached USB-passthrough handle:
/dev/serial/by-id/... symlinks and lsusb entries persist
after the disable. ssh hamster usbhub-helper status is the
source of truth, not VM-side device enumeration. The wrapper
queries hamster after each profile transition and emits the
authoritative state into the per-test log as [CI_HW] hamster_status=....
Troubleshooting
| Symptom | Action |
|---|---|
| post-commit looks dead | `ps -ef |
| Notification never arrived | Check last-results.txt. On headless boxes notifications are dropped. |
| Tier 1 spuriously red | Check log; if Docker is involved, ensure no leftover containers (docker ps -a) |
| Timer didn’t fire | systemctl --user list-timers, then journalctl --user -u leviculum-ci-tier2.timer |
| Stale-block annoying | git push --no-verify (one-shot) or run just extensive |
| Disk filling up | Logs auto-rotate (14d/60d), but ~/.cache/leviculum-ci-target/ can grow large — clear with cargo clean --target-dir ~/.cache/leviculum-ci-target |
Reticulum Protocol Specification
This appendix is an exact, English specification of the Reticulum (RNS) protocol,
derived from and proven against the vendored Python reference
(vendor/Reticulum, RNS 1.3.5, commit d5e62d4). It is the wire contract the
libreticulum reticulum-core/reticulum-std crates interoperate against, and
the foundation the LXMF Protocol Specification builds
on.
Reticulum is a cryptography-based networking stack: self-sovereign identities, addressable destinations, encrypted packets, announces for discovery, links for sessions, resources for large transfers, and channels for ordered messaging, all medium-agnostic above a thin framing layer.
How to read this specification
- Normative statements use RFC 2119 keywords (MUST, SHOULD, MAY). Every
normative fact carries a
file:linecitation into the reference, a derivation with the arithmetic shown, or a labelled test vector[VEC-...]. - Informative sections describe internal reference behaviour (transport tables, retransmission timing, resource window adaptation, keepalive math) that an implementation MAY diverge from without breaking wire or semantic compatibility.
- Test vectors are the genuine byte output of the reference, regenerated by
vectors/gen_vectors.pyand pinned to the submodule commit. Ephemeral-key paths (encryption token, announces, link handshake) are frozen by injecting fixed randomness and time, and additionally carry a decrypt/verify/derive roundtrip so the semantic property is proven too.
Sections
- Introduction and scope
- Cryptographic primitives
- Identity
- Destination
- Packet format
- Announce
- Link
- Resource
- Channel and Buffer
- Transport
- Framing and IFAC
- Constants reference
- Coverage ledger
- Test vectors
- Symbol inventory (frozen)
Introduction and scope
Reference
This specification describes Reticulum as implemented by the pinned reference:
| Component | Version | Commit |
|---|---|---|
| Reticulum (RNS) | 1.3.5 | d5e62d4e15c5fe2e170f7bd9e120551671f21a27 |
Citations reference files under vendor/Reticulum/RNS/ unless another path is
given.
Scope
Normative (specified exactly and proven):
- the cryptographic primitives as used (hashes, X25519, Ed25519, AES-CBC, HMAC, HKDF, the encryption token);
- identity key material, hashing, signing, and the ECIES encryption token;
- destination naming, hash derivation, and types;
- the packet header bitfield, both header types, addressing, context bytes, and sizes;
- announce layout, signing, and validation;
- link establishment (request, proof, link id, session key) and the link context-byte payloads;
- resource advertisement, parts, hashmap, requests, and proof;
- channel envelope and stream-data framing;
- the transport path request and path response packets;
- byte-stream framing (HDLC/KISS) and IFAC masking.
Informative (described, not byte-proven; an implementation MAY diverge):
- transport routing internals: path/announce/link/reverse/tunnel tables and their index shapes, announce retransmission timing and jitter, deduplication, table TTLs;
- resource flow-control window adaptation and timeout factors;
- keepalive interval math and link watchdog scheduling.
Out of scope: interface drivers beyond framing, the daemon/CLI tooling, and the shared-instance IPC.
The full enumeration and classification of reference symbols is the frozen Symbol inventory. The Coverage ledger maps every normative symbol to a section and a proof; a normative symbol with no mapping is a coverage gap.
Notational conventions
- RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY) mark normative requirements.
- Citations take the form
(Packet.py:177). - Byte layouts are shown as offset tables or annotated hex; concatenation is
a || b; a field width isname(16). - Test vectors are referenced by label
[VEC-...]and listed in full in Test vectors; machine-readable invectors/vectors.json. - Hashes are SHA-256 unless stated; the truncated hash is its leading 16
bytes (
Identity.py:383). Integers in masks/derivations are big-endian.
Vector kinds
- frozen — deterministic; the hex is the proof; reproduces byte for byte.
- frozen-injection — an ephemeral-key path (token, announce, link handshake,
path request) made reproducible by pinning
os.urandom,time, and X25519 ephemeral key generation in the harness, with a decrypt/verify/derive roundtrip recorded as the semantic proof. - computed — reconstructed per the source layout where building a live object needs a running link or transport (HEADER_2 packet, resource advertisement); the construction is byte-exact and cited.
Regenerating the vectors
From the repository root:
PYTHONPATH=vendor/Reticulum \
python3 docs/src/appendix/reticulum/vectors/gen_vectors.py
The harness boots a headless RNS.Reticulum instance, fixes all key material,
runs the genuine reference code, asserts determinism for every frozen and
frozen-injection vector by rebuilding and comparing, and writes
vectors/vectors.json with the submodule commit in its meta block. Re-running
MUST reproduce the committed file byte for byte.
Cryptographic primitives
Reticulum builds every wire surface on a small set of primitives. An implementation MUST produce byte-identical results to these, because their outputs are hashed, signed, and exchanged on the wire.
Hashing
full_hash(x)is SHA-256 overx, 32 bytes (Identity.HASHLENGTH = 256bits,Identity.py:80,373).truncated_hash(x)is the leading 16 bytes offull_hash(x)(TRUNCATED_HASHLENGTH = 128bits,Identity.py:84,383).
Proven by [VEC-HASH]: full_hash("reticulum-spec") = 659fe249468c635cdfe90a12624abec49f0bd36ba66d467b4f7155c79e8addf2, truncated to
659fe249468c635cdfe90a12624abec4.
Key exchange and signing
- X25519 (
Cryptography/X25519.py): 32-byte keys;exchangeis constant-time ECDH yielding a 32-byte shared secret. Deterministic for a given key pair. - Ed25519 (
Cryptography/Ed25519.py): 32-byte seed, 32-byte public key, 64-byte signature; deterministic per RFC 8032.sign(m)andverify(sig, m).
Symmetric encryption
- AES-128/256-CBC (
Cryptography/AES.py) with a 16-byte IV and PKCS7 padding (Cryptography/PKCS7.py, block size 16).[VEC-AES]:AES-256-CBCof one block"0123456789abcdef"under key00010203…1fand IV000102…0fise23fc0b91c7bd64425c559736e9b0c58, and decrypts back.
HMAC and HKDF
- HMAC-SHA256 (
Cryptography/HMAC.py, RFC 2104), 32-byte digest. - HKDF-SHA256 (
Cryptography/HKDF.py:35),hkdf(length, derive_from, salt, context).[VEC-HKDF]:hkdf(32, derive_from=00..1f, salt=00..0f) = 2bc3faec9f360e81e77086b6e17a9ce8722a4cb3bc0ed90b4d78d37036e43a0f.
Encryption token (modified Fernet)
The token (Cryptography/Token.py) is the AEAD-like envelope Reticulum uses for
SINGLE-destination and link encryption. Its layout is:
IV(16) || AES-CBC(plaintext, derived_key, IV) || HMAC-SHA256(...)(32)
with TOKEN_OVERHEAD = 48 (IV 16 + HMAC 32, Token.py:50). The HMAC
authenticates the IV and ciphertext; decrypt MUST verify it before decrypting
(Token.py:77,100). The full ECIES wrapper that prepends the ephemeral public
key is specified in Identity and proven by [VEC-ID-TOKEN].
Determinism
Hashing, HKDF, HMAC, Ed25519 signing, X25519 (given the keys), and AES-CBC (given key and IV) are deterministic and yield frozen vectors. Anything that generates an ephemeral key or IV (the encryption token, announces, link handshake) is non-deterministic in normal operation; this specification freezes those by injecting fixed randomness in the harness and additionally proves them by roundtrip (see Test vectors).
Identity
An identity is a pair of key pairs: X25519 for encryption and Ed25519 for
signing. This section is proven by [VEC-ID-HASH], [VEC-ID-SIGN], and
[VEC-ID-TOKEN].
Key material
The public key is the concatenation (Identity.py:757):
public_key = X25519_public(32) || Ed25519_public(32) # 64 bytes
and the private key is X25519_private(32) || Ed25519_seed(32)
(Identity.py:750,768-777). KEYSIZE = 512 bits (Identity.py:59),
SIGLENGTH = 512 bits (:81). [VEC-ID-HASH] records a 64-byte public key from
the fixed private material 00010203…3f.
Identity hash
identity_hash = truncated_hash(X25519_public || Ed25519_public) # 16 bytes
(Identity.py:805-810). [VEC-ID-HASH]: identity hash aca31af0441d81dbec71e82da0b4b5f5.
Name hash
NAME_HASH_LENGTH = 80 bits (Identity.py:83); the name hash is the leading 10
bytes of full_hash of the dotted destination name. Used in destination hashing
and announces; see Destination.
Signing
sign(m)is Ed25519 overm, 64 bytes, deterministic (Identity.py:931-941).validate(sig, m)verifies it (Identity.py:948-964).
[VEC-ID-SIGN]: signing the fixed message yields signature
bbfdcde5aa05197f… and validate returns true.
Encryption token (ECIES)
encrypt(plaintext) to a SINGLE destination produces (Identity.py:827-857):
ephemeral_X25519_public(32) || token(IV(16) || AES-CBC ciphertext || HMAC(32))
The derivation:
- generate an ephemeral X25519 key pair (
Identity.py:836); shared = ephemeral_private.exchange(target_X25519_public)(:844);derived_key = hkdf(length=64, derive_from=shared, salt=target_identity_hash, context=None)(:846-851);token = Token(derived_key).encrypt(plaintext)(:854).
DERIVED_KEY_LENGTH = 64 bytes (Identity.py:90): 32 for the AES-256 key and 32
for the HMAC key. decrypt recovers the ephemeral public key from the first 32
bytes, re-derives the key, and (when ratchets are present) tries each ratchet
before the base identity (Identity.py:872-928).
Because the ephemeral key and IV are random, the token is not reproducible in
normal operation. [VEC-ID-TOKEN] is a frozen-injection vector: under pinned
randomness the 112-byte token (32 ephemeral + 48 overhead + 32 ciphertext for a
27-byte plaintext) reproduces byte for byte, and decrypt(token) == plaintext
holds. An implementation MUST reproduce this construction so a Python peer can
decrypt its packets.
Ratchets
A ratchet is an ephemeral X25519 key offering forward secrecy. RATCHETSIZE = 256 bits (Identity.py:64); the ratchet id is the leading 10 bytes of
full_hash of the ratchet public key (_get_ratchet_id, Identity.py:417). A
destination MAY advertise its current ratchet in an announce (see
Announce); a sender then encrypts to the ratchet public key
instead of the identity’s static key. Ratchet rotation and expiry
(RATCHET_EXPIRY, RATCHET_INTERVAL) are informative.
Destination
A destination is an addressable endpoint. This section is proven by
[VEC-DEST-HASH].
Naming and hashing
A destination name is the dotted concatenation of an app name and aspects, e.g.
test.vec (expand_name, Destination.py:96). The hashes are
(Destination.py:116-141):
name_hash = full_hash(app_name [. aspect ...])[:10] # 80 bits
destination_hash = truncated_hash(name_hash || identity_hash) # 16 bytes
[VEC-DEST-HASH] for app test, aspect vec, identity hash
069092a03c194639207219dd05f9c840: name hash 9da53eec82a28ce2f2e9 (10 bytes),
destination hash 07d4541d4fdc0abfacc9364fdf979ee1 (16 bytes). An implementation
MUST derive these identically, since the destination hash is the on-wire address
and is recomputed by every receiver of an announce.
Types
| Type | Value | Encryption | Citation |
|---|---|---|---|
SINGLE | 0x00 | per-recipient ECIES token to the identity (or ratchet) | Destination.py:63 |
GROUP | 0x01 | symmetric (shared key, not auto-distributed) | :64 |
PLAIN | 0x02 | none (cleartext) | :65 |
LINK | 0x03 | per-link session key | :66 |
The type occupies bits 3-2 of the packet header (see Packet format).
Direction and proof strategy
- Direction:
IN = 0x11,OUT = 0x12(Destination.py:79-80). OnlyINSINGLE destinations may be announced (Destination.py:251-255). - Proof strategy:
PROVE_NONE = 0x21,PROVE_APP = 0x22,PROVE_ALL = 0x23(Destination.py:69-71) controls whether the destination automatically returns delivery proofs (see Packet format proofs).
Encryption and decryption
Destination.encrypt/decrypt (Destination.py:585,611) delegate to the
identity’s token for SINGLE destinations, applying the current ratchet when
enabled. This is the path LXMF and link setup use for SINGLE-addressed payloads.
Packet format
This section is normative and proven by [VEC-PKT-PLAIN], [VEC-PKT-ENC], and
[VEC-PKT-HEADER2].
Header bitfield
The first byte is a bitfield (Packet.get_packed_flags, Packet.py:169-175;
decoded in unpack, :247-251):
bit 7 IFAC flag (set by the interface, not by pack; see Framing/IFAC)
bit 6 header type 0 = HEADER_1, 1 = HEADER_2
bit 5 context flag context-specific; set when an announce carries a ratchet
bit 4 transport type 0 = BROADCAST, 1 = TRANSPORT
bits3-2 destination type 00 SINGLE, 01 GROUP, 10 PLAIN, 11 LINK
bits1-0 packet type 00 DATA, 01 ANNOUNCE, 10 LINKREQUEST, 11 PROOF
packed_flags = (header_type<<6) | (context_flag<<5) | (transport_type<<4) | (destination_type<<2) | packet_type.
HEADER_1 layout
flags(1) || hops(1) || destination_hash(16) || context(1) || data
offset 0 1 2 18 19
(Packet.pack, Packet.py:177-239; unpack, :262-264). The fixed header is
HEADER_MINSIZE = 19 bytes (Reticulum.py:147).
[VEC-PKT-PLAIN] is a PLAIN HEADER_1 DATA packet,
0800fc0910664040482cd653166c8f225520006869:
08 flags: PLAIN(0x08) + DATA, HEADER_1, BROADCAST
00 hops
fc0910664040482cd653166c8f225520 destination_hash(16)
00 context (NONE)
6869 data = "hi"
The flags decode to ifac 0, header_type 0, context_flag 0, transport_type 0,
destination_type 2 (PLAIN), packet_type 0 (DATA). [VEC-PKT-ENC] is the SINGLE
encrypted equivalent (flags 00); under injection its encrypted body reproduces
byte for byte.
HEADER_2 layout
When the header type bit is set, a 16-byte transport id precedes the destination
hash (Packet.py:255-259):
flags(1) || hops(1) || transport_id(16) || destination_hash(16) || context(1) || data
offset 0 1 2 18 34 35
HEADER_MAXSIZE = 35 bytes (Reticulum.py:148). [VEC-PKT-HEADER2] shows the
constructed layout 4000 a0..af b0..bf 00 64617461: flags 40 (header_type 1),
transport id a0a1…af, destination hash b0b1…bf. HEADER_2 is emitted by
transport nodes forwarding toward a known next hop.
Sizes
MTU = 500 (Reticulum.py:93)
HEADER_MAXSIZE = 2 + (128/8)*2 + 1 = 35 (Reticulum.py:148)
MDU = 500 - 35 - 1 = 464 (Reticulum.py:152)
Packet.ENCRYPTED_MDU = 383 (Packet.py:106)
Packet.PLAIN_MDU = MDU = 464 (Packet.py:110)
Context bytes
The context byte (offset 18 for HEADER_1) selects the packet’s role within its
type. Full set (Packet.py:72-92):
| Hex | Name | Used by |
|---|---|---|
| 0x00 | NONE | generic data |
| 0x01 | RESOURCE | resource part |
| 0x02 | RESOURCE_ADV | resource advertisement |
| 0x03 | RESOURCE_REQ | resource part request |
| 0x04 | RESOURCE_HMU | resource hashmap update |
| 0x05 | RESOURCE_PRF | resource proof |
| 0x06 | RESOURCE_ICL | resource initiator cancel |
| 0x07 | RESOURCE_RCL | resource receiver cancel |
| 0x08 | CACHE_REQUEST | cache request |
| 0x09 | REQUEST | link request (application) |
| 0x0A | RESPONSE | link response |
| 0x0B | PATH_RESPONSE | transport path response |
| 0x0C | COMMAND | command |
| 0x0D | COMMAND_STATUS | command status |
| 0x0E | CHANNEL | channel data |
| 0xFA | KEEPALIVE | link keepalive |
| 0xFB | LINKIDENTIFY | link identification |
| 0xFC | LINKCLOSE | link close |
| 0xFD | LINKPROOF | (deprecated) |
| 0xFE | LRRTT | link RTT |
| 0xFF | LRPROOF | link request proof |
Proofs
A delivery proof is a PROOF packet over the original packet hash
(Packet.get_hashable_part, :355; validate_proof, :498). Two forms exist:
explicit (packet_hash(32) || signature(64), EXPL_LENGTH = 96) and implicit
(signature(64), IMPL_LENGTH = 64). The reference currently emits explicit
proofs (Link.prove_packet, Link.py:390). Whether a destination proves is
governed by its proof strategy (see Destination).
Announce
An announce broadcasts a destination’s keys so peers can address and route to it.
This section is proven by [VEC-ANN-NORATCHET] and [VEC-ANN-RATCHET].
Packet
An announce is an ANNOUNCE-type packet (packet_type = 0x01) to the announced
SINGLE destination. The context flag (header bit 5) is set when a ratchet is
included (Destination.py:310-311). [VEC-ANN-NORATCHET] flags byte 01
(ANNOUNCE, context flag 0); [VEC-ANN-RATCHET] flags byte with context flag 1.
Announce data layout
The packet data is (Destination.py:301):
public_key(64) || name_hash(10) || random_hash(10) || [ratchet(32)] || signature(64) || app_data
The ratchet field is present only when the context flag is set. Without it the
data is 64 + 10 + 10 + 64 = 148 bytes plus app_data; with it, 180 plus
app_data. [VEC-ANN-NORATCHET] carries 150 data bytes (148 + 2-byte app_data);
[VEC-ANN-RATCHET] carries 182 (180 + 2).
The random_hash is get_random_hash()[0:5] || int(time.time()).to_bytes(5, "big") (Destination.py:282): 5 random bytes and a
5-byte big-endian Unix timestamp. It makes each announce unique and lets
receivers reject replays.
Signed data
The signature covers (Destination.py:297-300):
signature = sign( destination_hash || public_key || name_hash || random_hash || [ratchet] || app_data )
The destination hash is signed but not transmitted in the announce data; the receiver recomputes it from the transmitted keys (below). This binds the keys to the address without spending 16 bytes on the wire.
Validation
A receiver validates an announce by (Identity.py:532-634):
- parsing
public_key = data[:64], thenname_hash,random_hash, optionalratchet(when the context flag is set),signature, andapp_dataat the offsets above (Identity.py:546-564); - reconstructing
signed_dataand verifying the signature against the transmitted public key (Identity.py:579); - recomputing
expected_destination_hash = truncated_hash(name_hash || identity_hash)and checking it matches (Identity.py:584-585); - remembering the public key, app_data, and (if present) the ratchet for future
encryption (
Identity.py:598,618-619).
[VEC-ANN-NORATCHET] and [VEC-ANN-RATCHET] are frozen-injection vectors:
under pinned randomness and time the announce reproduces byte for byte, and the
genuine validate_announce returns true. An implementation MUST reproduce the
signed-data order and the destination-hash recomputation, or a Python peer
rejects the announce.
Path response
A path response is an announce re-emitted with context PATH_RESPONSE (0x0B)
rather than NONE, in reply to a path request (see Transport).
The announce data is identical; only the packet context differs.
Link
A link is an ephemeral, forward-secret session between two destinations,
established by an ECDH handshake. This section is proven by [VEC-LINK].
Link request
The initiator sends a LINKREQUEST packet (packet_type = 0x02) whose data is
(Link.py:308-317):
ephemeral_X25519_public(32) || ephemeral_Ed25519_public(32) || signalling(3)
ECPUBSIZE = 64 (the two public keys). The 3-byte signalling field encodes the
proposed link MTU (21 bits) and mode (3 bits): (mtu & 0x1FFFFF) | ((mode<<5 & 0xE0)<<16), packed big-endian, low 3 bytes (Link.signalling_bytes,
Link.py:148-151). The only enabled mode is MODE_AES256_CBC = 0x01.
Link id
hashable = packet.get_hashable_part() # masked-flags byte || addressing || data
if len(packet.data) > ECPUBSIZE: # strip trailing signalling bytes
hashable = hashable[:-(len(packet.data) - ECPUBSIZE)]
link_id = truncated_hash(hashable) # 16 bytes
(Link.link_id_from_lr_packet, Link.py:340-347): the hashable part is trimmed by
the number of bytes the request data exceeds the 64-byte key block (i.e. the
signalling bytes) before hashing.
[VEC-LINK] link id 4725ac1375601d182afec3610f019b25. The link id replaces the
destination hash in the addressing of all subsequent link packets, and is also
the link’s salt (below).
Proof and handshake
The responder replies with a PROOF packet, context LRPROOF (0xFF), data
(Link.py:371-377):
signature(64) || ephemeral_X25519_public(32) || signalling(3)
where signature = sign( link_id || responder_eph_X25519_pub || responder_eph_Ed25519_pub || signalling ) (Link.py:373). The initiator
validates it against the destination’s known identity (Link.py:417-420).
Both sides then derive the session key (Link.handshake, Link.py:353-366):
shared = own_ephemeral_private.exchange(peer_ephemeral_public) # X25519 ECDH
session_key = hkdf(length=64, derive_from=shared, salt=link_id, context=None)
64 bytes for MODE_AES256_CBC (32 key + 32 HMAC). get_salt() returns the link
id and get_context() returns None (Link.py:643,646). [VEC-LINK] proves
the ECDH is symmetric (ecdh_agreement = true, both sides compute the same
shared secret) and records the resulting 64-byte session key
569ac51a07fb242f…. An implementation MUST derive the link id and session key
identically.
Link operations (context bytes)
Once active, link packets are DATA packets addressed by link id, encrypted with the session key, distinguished by context byte:
| Context | Hex | Encrypted | Payload | Citation |
|---|---|---|---|---|
LRPROOF | 0xFF | no | sig(64) + eph_pub(32) + signalling(3) | Link.py:371-377 |
LRRTT | 0xFE | yes | msgpack(float rtt) | Link.py:440 |
LINKIDENTIFY | 0xFB | yes | identity_public(32) + sign(link_id||public)(64) | Link.py:459-471 |
KEEPALIVE | 0xFA | no | single byte 0xFF | Link.py:848-851 |
LINKCLOSE | 0xFC | yes | link_id | — |
After proof, the initiator measures RTT and sends an LRRTT packet; either side
MAY identify (prove an identity over the link) by sending a LINKIDENTIFY
packet. Keepalive cadence, the stale/close watchdog, and MTU discovery are
informative.
Resource
A resource is a reliable, segmented transfer over a link for data larger than a
single packet. This section is proven by [VEC-RES-ADV] and [VEC-RES-PROOF].
Window adaptation and timeout scheduling are informative.
Advertisement
The sender advertises a resource with a RESOURCE_ADV packet (context 0x02)
carrying a msgpack dictionary (ResourceAdvertisement.pack, Resource.py:1333-1355):
| Key | Meaning |
|---|---|
t | transfer size (encrypted bytes) |
d | total uncompressed data size |
n | number of parts |
h | resource hash (32) |
r | random hash (4) |
o | original (first-segment) hash (32) |
i | segment index |
l | total segments |
q | associated request id, or nil |
f | flags byte |
m | hashmap segment (4-byte MAPHASH_LEN entries) |
The flags byte is (Resource.py:1307):
f = (has_metadata<<5) | (is_response<<4) | (is_request<<3) | (split<<2) | (compressed<<1) | encrypted
[VEC-RES-ADV] is a computed vector (a live advertisement needs a Resource
over a link): the dictionary with compressed and encrypted set yields flags
03 and packs to 146 bytes under the genuine msgpack. MAPHASH_LEN = 4 and
RANDOM_HASH_SIZE = 4.
Parts, requests, and hashmap
- A part is a RESOURCE packet (context 0x01) carrying up to one SDU of pre-encrypted data.
- The receiver requests parts with a RESOURCE_REQ packet (context 0x03) whose
first byte is the hashmap status (
HASHMAP_IS_EXHAUSTED = 0xFF/HASHMAP_IS_NOT_EXHAUSTED = 0x00), optionally followed by the last received map index and the requested 4-byte part hashes. - The sender extends the hashmap with a RESOURCE_HMU packet (context 0x04)
carrying
resource_hash || msgpack([segment_index, hashmap_bytes]).
The hashmap lets the receiver request parts by hash, and the sender stream
hashes as the window advances. The sliding window sizes (WINDOW, WINDOW_MIN,
WINDOW_MAX*) and the part/proof timeout factors are informative.
Proof and cancellation
On completion the receiver assembles the parts, verifies integrity, and the
sender sends a RESOURCE_PRF packet (context 0x05, unencrypted) carrying
(Resource.py:755-756):
proof = full_hash(data || resource_hash)
proof_data = resource_hash(32) || proof(32)
a single SHA-256 over the assembled data concatenated with the resource hash,
prefixed by the resource hash. validate_proof accepts when proof_data is 64
bytes and its second half matches the expected proof (Resource.py:782-786).
[VEC-RES-PROOF] records this construction. Either party may abort with
RESOURCE_ICL (0x06, initiator) or RESOURCE_RCL (0x07, receiver).
Compression and segmentation
A resource MAY be bz2-compressed (the compressed flag) and, when larger than a
single segment, split into total_segments segments chained by the original
hash. The maximum efficient single-segment size and metadata limits are
implementation guidance.
Channel and Buffer
A channel is an ordered, typed messaging layer over a link; a buffer is a
byte-stream abstraction built on a channel. This section is proven by
[VEC-CHAN-ENVELOPE] and [VEC-STREAM-HDR].
Channel envelope
Every channel message is wrapped in an envelope (Channel.Envelope.pack,
Channel.py:174-200):
msgtype(u16, big-endian) || sequence(u16, big-endian) || length(u16, big-endian) || payload
[VEC-CHAN-ENVELOPE]: msgtype 0xabcd, sequence 7, payload "channeldata" packs
to abcd0007000b6368616e6e656c64617461 — abcd type, 0007 sequence, 000b
length 11, then the payload. Message types 0xF000 and above are reserved for
system messages.
Stream data message
The buffer layer sends StreamDataMessages (type SMT_STREAM_DATA = 0xff00) over
a channel. Each carries a 2-byte header (Buffer.py:80-92):
header(u16, big-endian):
bits 0-13 stream_id (0..STREAM_ID_MAX = 0x3fff)
bit 14 compressed
bit 15 eof
then: data
header = (stream_id & 0x3fff) | (0x8000 if eof) | (0x4000 if compressed).
[VEC-STREAM-HDR]: stream id 0x0102, eof set, not compressed, payload
"streamdata" packs to 810273747265616d64617461 — 8102 header (eof bit set
over stream id 0x0102), then the data.
The combined overhead is 8 bytes (2-byte stream header + 6-byte channel envelope),
so MAX_DATA_LEN = Link.MDU - 8. Channel sequencing, acknowledgement, and
retransmission are informative.
Transport
Transport routes packets across the mesh: it discovers paths, forwards packets
toward known next hops, and propagates announces. The wire surfaces (path
request and path response packets) are normative and proven by
[VEC-PATH-REQUEST]. The routing internals (tables, retransmission timing,
deduplication, TTLs) are informative.
Path request
To discover a path, a node sends a path request: a DATA packet to the PLAIN
destination rnstransport.path.request (Transport.request_path,
Transport.py:2786-2787). The payload is (Transport.py:2783-2784):
target_destination_hash(16) || [transport_identity_hash(16)] || request_tag
The transport_identity_hash is included only when transport is enabled on the
requesting node; the request_tag is a random hash that deduplicates the request
(Transport.py:2780). [VEC-PATH-REQUEST] is a frozen-injection vector with
transport disabled: payload target(16) || tag(16), in a PLAIN HEADER_1 packet
(flags 08). An implementation MUST address the request to this PLAIN
destination and use this payload order so existing nodes answer it.
Path response
A node that holds a path answers by re-emitting the destination’s cached announce
with the packet context set to PATH_RESPONSE (0x0B) instead of NONE
(Transport.py:2943-2972). The announce data is byte-identical to an ordinary
announce (see Announce); only the context differs, and the
rebroadcast is marked so it is not propagated further.
Routing internals (informative)
The following are reference behaviour an implementation MAY diverge from:
- Tables. Transport maintains path, announce, reverse, link, and tunnel
tables, each a list indexed by the
IDX_*constants (Transport.py:3547-3586). A path entry holds timestamp, next hop, hops, expiry, the announce random blobs, the receiving interface, and the cached packet hash. - Announce propagation. Announces are rebroadcast with a hop limit
PATHFINDER_M = 128, up toLOCAL_REBROADCASTS_MAX = 2local rebroadcasts, after a gracePATHFINDER_G = 5 splus random jitterPATHFINDER_RW = 0.5 s(Transport.py:63-77). - Path TTLs. Default
PATHFINDER_E = 7 days; access-point pathsAP_PATH_TIME = 1 day; roaming pathsROAMING_PATH_TIME = 6 hours(Transport.py:71-73). - Deduplication. A rolling table of recent packet hashes suppresses loops.
- Path request pacing.
PATH_REQUEST_TIMEOUT = 15 s,PATH_REQUEST_MI = 20 sminimum interval, and per-interface announce caps (Transport.py:79-83).
These values and structures are documented for fidelity; only the path request and path response packets above are normative.
Framing and IFAC
This section covers how packets are delimited on byte-stream interfaces (HDLC)
and how an interface authenticates and masks packets (IFAC). Both are normative
for wire interop on those media and proven by [VEC-HDLC] and [VEC-IFAC].
Medium specifics beyond framing (LoRa airtime, TCP particulars) are informative.
HDLC byte-stream framing
Byte-stream interfaces (TCP, serial, pipe) delimit packets with HDLC-style flags
and byte stuffing (Interfaces/TCPInterface.py:44-52,323):
FLAG = 0x7E
ESC = 0x7D
ESC_MASK = 0x20
frame = FLAG || escape(packet) || FLAG
escape: replace 0x7D -> 0x7D 0x5D, then 0x7E -> 0x7D 0x5E
A literal flag or escape byte inside the packet is replaced by the escape byte
followed by the original XORed with 0x20. [VEC-HDLC]: input 01 7E 02 7D 03
frames to 7e017d5e027d5d037e — leading flag, 01, escaped 7E→7D5E, 02,
escaped 7D→7D5D, 03, trailing flag. A receiver un-stuffs by reversing the
replacement between flags. (KISS interfaces use the analogous FEND/FESC framing.)
IFAC (interface access codes)
An interface configured with a passphrase derives a 64-byte IFAC identity and an
IFAC key, then authenticates and masks every packet (Transport.transmit,
Transport.py:1051-1087):
ifac = ifac_identity.sign(raw)[-ifac_size:]
mask = hkdf(length = len(raw) + ifac_size, derive_from = ifac, salt = ifac_key, context = None)
new_raw = (raw[0] | 0x80) || raw[1] || ifac || raw[2:]
masked[i] = new_raw[i] ^ mask[i] for i == 0 (then re-set bit 7), i == 1, and i > ifac_size+1
masked[i] = new_raw[i] for the ifac bytes (2 .. ifac_size+1, left unmasked)
The IFAC flag (header bit 7) is set, the ifac tag is inserted right after the
two header bytes, and everything except the tag itself is XOR-masked with the
HKDF stream. On receipt the interface reverses the mask, extracts the tag,
recomputes ifac_identity.sign(recovered_raw)[-ifac_size:], and drops the packet
on mismatch (Transport.inbound, Transport.py:1398-1434).
[VEC-IFAC] (ifac_size = 8) records the tag, the mask, the masked output
9f4a2cf485c1dfcea0…, the IFAC flag set in the masked header, and proves a full
mask/unmask roundtrip recovers the original packet and a matching tag
(unmask_roundtrip_ok = true). IFAC_MIN_SIZE = 1, and IFAC_SALT is a fixed
32-byte constant (Reticulum.py:149-150). An implementation sharing an interface
with Python peers MUST reproduce this masking exactly or its packets are dropped.
Constants reference
Grouped constants with values and citations. Derived sizes are captured in
vectors.json constants.
System (Reticulum.py)
| Constant | Value | Line |
|---|---|---|
MTU | 500 | 93 |
MDU | 464 | 152 |
TRUNCATED_HASHLENGTH | 128 bits | 145 |
HEADER_MINSIZE | 19 | 147 |
HEADER_MAXSIZE | 35 | 148 |
IFAC_MIN_SIZE | 1 | 149 |
Identity (Identity.py)
| Constant | Value | Line |
|---|---|---|
KEYSIZE | 512 bits | 59 |
RATCHETSIZE | 256 bits | 64 |
TOKEN_OVERHEAD | 48 | 77 |
HASHLENGTH | 256 bits | 80 |
SIGLENGTH | 512 bits | 81 |
NAME_HASH_LENGTH | 80 bits | 83 |
DERIVED_KEY_LENGTH | 64 | 90 |
Destination (Destination.py)
SINGLE 0x00, GROUP 0x01, PLAIN 0x02, LINK 0x03 (63-66); PROVE_NONE 0x21,
PROVE_APP 0x22, PROVE_ALL 0x23 (69-71); IN 0x11, OUT 0x12 (79-80).
Packet (Packet.py)
Packet types DATA 0x00, ANNOUNCE 0x01, LINKREQUEST 0x02, PROOF 0x03
(60-63). Header types HEADER_1 0x00, HEADER_2 0x01 (67-68). FLAG_SET 0x01,
FLAG_UNSET 0x00 (95-96). ENCRYPTED_MDU 383 (106), PLAIN_MDU 464 (110).
Context bytes 0x00-0xFF (72-92) — full table in Packet format.
Proof lengths EXPL_LENGTH 96, IMPL_LENGTH 64.
Link (Link.py)
ECPUBSIZE 64, KEYSIZE 32, LINK_MTU_SIZE 3, MTU_BYTEMASK 0x1FFFFF,
MODE_BYTEMASK 0xE0, MODE_AES256_CBC 0x01. States PENDING 0x00 ..
CLOSED 0x04 (informative). Context bytes KEEPALIVE 0xFA, LINKIDENTIFY 0xFB,
LINKCLOSE 0xFC, LINKPROOF 0xFD, LRRTT 0xFE, LRPROOF 0xFF.
Resource (Resource.py)
MAPHASH_LEN 4, RANDOM_HASH_SIZE 4, HASHMAP_IS_EXHAUSTED 0xFF,
HASHMAP_IS_NOT_EXHAUSTED 0x00, advertisement OVERHEAD 134 (1235). Context
bytes RESOURCE 0x01 .. RESOURCE_RCL 0x07 (Packet.py:73-79). Window sizes and
timeout factors are informative.
Channel / Buffer (Channel.py, Buffer.py)
SMT_STREAM_DATA 0xff00, STREAM_ID_MAX 0x3fff, combined OVERHEAD 8.
Transport (Transport.py) — informative
PATHFINDER_M 128, PATHFINDER_R 1, PATHFINDER_G 5 s, PATHFINDER_RW 0.5 s,
PATHFINDER_E 7 days, AP_PATH_TIME 1 day, ROAMING_PATH_TIME 6 h,
LOCAL_REBROADCASTS_MAX 2, PATH_REQUEST_TIMEOUT 15 s, PATH_REQUEST_MI 20 s
(50-83). Table index constants IDX_* (3547-3586).
Coverage ledger
Traceability matrix from the frozen Symbol inventory to the
specification. Every normative (N) symbol maps to a section and a proof; every
informative (I) and out-of-scope (X) symbol carries a reason. Proof: vector
(a [VEC-...]), computed (derivation shown), quoted (value cited verbatim),
n/a (informative/out-of-scope).
Reticulum.py / Cryptography
| Symbol(s) | file:line | Class | Section | Proof |
|---|---|---|---|---|
| MTU, MDU, HEADER sizes, TRUNCATED_HASHLENGTH | Reticulum.py:93-152 | N | 04 | computed (vector constants) |
| IFAC_MIN_SIZE, IFAC_SALT | Reticulum.py:149-150 | N | 10 | quoted |
| full/truncated hash | Identity.py:373-390 | N | 01 | vector VEC-HASH |
| HKDF, HMAC, AES, PKCS7 | Cryptography/* | N | 01 | vector VEC-HKDF/HMAC/AES |
| Token (modified Fernet), TOKEN_OVERHEAD | Token.py | N | 01,02 | vector VEC-ID-TOKEN |
| X25519, Ed25519 | Cryptography/* | N | 01,02 | vector VEC-ID-SIGN/LINK |
Identity.py
| Symbol(s) | file:line | Class | Section | Proof |
|---|---|---|---|---|
| KEYSIZE, HASHLENGTH, SIGLENGTH, NAME_HASH_LENGTH, DERIVED_KEY_LENGTH, RATCHETSIZE | 59-90 | N | 02 | quoted/computed |
| key material, identity hash, get_public_key | 750-810 | N | 02 | vector VEC-ID-HASH |
| sign / validate | 931-964 | N | 02 | vector VEC-ID-SIGN |
| encrypt / decrypt (token) | 827-928 | N | 02 | vector VEC-ID-TOKEN |
| validate_announce | 532-634 | N | 05 | vector VEC-ANN-* |
| ratchet id / generation | 417-425 | N | 02 | quoted |
| RATCHET_EXPIRY, recall/remember | 69,— | I | 02 | n/a (rotation/resolution) |
Destination.py
| Symbol(s) | file:line | Class | Section | Proof |
|---|---|---|---|---|
| SINGLE/GROUP/PLAIN/LINK, PROVE_*, IN/OUT | 63-80 | N | 03,04 | quoted |
| name hash / destination hash | 116-141 | N | 03 | vector VEC-DEST-HASH |
| announce (data + signed data) | 243-317 | N | 05 | vector VEC-ANN-* |
| encrypt / decrypt | 585-611 | N | 03 | quoted (VEC-ID-TOKEN) |
| RATCHET_COUNT/INTERVAL, PR_TAG_WINDOW | 83-90 | I | 02 | n/a |
Packet.py
| Symbol(s) | file:line | Class | Section | Proof |
|---|---|---|---|---|
| packet types, header types, context bytes, FLAG_* | 60-96 | N | 04 | quoted |
| ENCRYPTED_MDU / PLAIN_MDU | 106-110 | N | 04 | computed |
| pack / unpack | 177-272 | N | 04 | vector VEC-PKT-PLAIN/ENC/HEADER2 |
| get_hashable_part, validate_proof, EXPL/IMPL_LENGTH | 355,498 | N | 04 | quoted |
| PacketReceipt states | 408-415 | I | 04 | n/a (local) |
Link.py
| Symbol(s) | file:line | Class | Section | Proof |
|---|---|---|---|---|
| ECPUBSIZE, KEYSIZE, LINK_MTU_SIZE, masks, MODE_AES256_CBC | — | N | 06 | quoted |
| link_id_from_lr_packet, set_link_id | 340-351 | N | 06 | vector VEC-LINK |
| handshake (session key), get_salt/get_context | 353-366,643 | N | 06 | vector VEC-LINK |
| prove, signalling_bytes | 371-377,148 | N | 06 | vector VEC-LINK |
| identify, send_keepalive, context payloads | 459,848 | N | 06 | quoted |
| states, KEEPALIVE/STALE timing, watchdog | — | I | 06 | n/a |
Resource.py
| Symbol(s) | file:line | Class | Section | Proof |
|---|---|---|---|---|
| ResourceAdvertisement.pack, flags, keys | 1278-1355 | N | 07 | vector VEC-RES-ADV |
| MAPHASH_LEN, RANDOM_HASH_SIZE, HASHMAP_* | — | N | 07 | quoted |
| prove / validate_proof | 752-786 | N | 07 | vector VEC-RES-PROOF |
| context bytes RESOURCE..RESOURCE_RCL | Packet.py:73-79 | N | 04,07 | quoted |
| WINDOW*, timeout factors, advertise/assemble | — | I | 07 | n/a (flow control) |
| status enum | — | I | 07 | n/a |
Channel.py / Buffer.py
| Symbol(s) | file:line | Class | Section | Proof |
|---|---|---|---|---|
| Envelope.pack/unpack | Channel.py:174-200 | N | 08 | vector VEC-CHAN-ENVELOPE |
| StreamDataMessage header, SMT_STREAM_DATA, STREAM_ID_MAX, OVERHEAD | Buffer.py:80-92 | N | 08 | vector VEC-STREAM-HDR |
| MessageState, CEType, sequencing | — | I | 08 | n/a |
Transport.py
| Symbol(s) | file:line | Class | Section | Proof |
|---|---|---|---|---|
| transmit / inbound (IFAC) | 1051-1434 | N | 10 | vector VEC-IFAC |
| request_path (path request) | 2771-2787 | N | 09 | vector VEC-PATH-REQUEST |
| path response (PATH_RESPONSE rebroadcast) | 2943-2972 | N | 09 | quoted |
| PATHFINDER_*, path TTLs, pacing | 50-83 | I | 09 | n/a (routing) |
| path/announce/link/reverse/tunnel tables, IDX_* | 3547-3586 | I | 09 | n/a (internal) |
| dedup, jobs, table culling | 508,— | I | 09 | n/a |
Interfaces
| Symbol(s) | file:line | Class | Section | Proof |
|---|---|---|---|---|
| HDLC FLAG/ESC/ESC_MASK, escape | TCPInterface.py:44-52,323 | N | 10 | vector VEC-HDLC |
| KISS FEND/FESC framing | KISSInterface.py | N | 10 | quoted |
| interface drivers (TCP/LoRa/serial specifics) | Interfaces/* | X | — | n/a (medium drivers) |
Result
Every normative symbol maps to a section and a proof; no normative row has an
empty section or n/a proof. Informative and out-of-scope rows are reasoned.
Coverage is complete against the frozen inventory at commit d5e62d4. Re-auditing
after a reference bump: re-enumerate the source and diff the inventory; a new
symbol appears here unclassified.
Test vectors
The golden vectors that prove the binary claims in this specification. They are
the genuine output of the reference, generated by
vectors/gen_vectors.py and stored in
vectors/vectors.json.
Regenerate and verify (from the repository root):
PYTHONPATH=vendor/Reticulum \
python3 docs/src/appendix/reticulum/vectors/gen_vectors.py
Pinned to RNS 1.3.5 @ d5e62d4. Fixed inputs: source identity private =
0001…3f, destination identity private = 4041…7f, time = 1700000000.0.
Vector kinds
- frozen — deterministic; the hex is the proof.
- frozen-injection — ephemeral-key path made reproducible by pinning
os.urandom/time/X25519 generation, with a decrypt/verify/derive roundtrip. - computed — reconstructed per source layout where a live link/transport is needed; byte-exact and cited.
Primitives
- VEC-HASH (frozen,
Identity.py:373-390):full_hash("reticulum-spec") = 659fe249468c635cdfe90a12624abec49f0bd36ba66d467b4f7155c79e8addf2; truncated659fe249468c635cdfe90a12624abec4. - VEC-HKDF (frozen,
HKDF.py:35):hkdf(32, 00..1f, salt=00..0f) = 2bc3faec9f360e81e77086b6e17a9ce8722a4cb3bc0ed90b4d78d37036e43a0f. - VEC-HMAC (frozen): HMAC-SHA256 over the fixed key/message.
- VEC-AES (frozen): AES-256-CBC one block →
e23fc0b91c7bd64425c559736e9b0c58, decrypts back.
Identity
- VEC-ID-HASH (frozen): 64-byte public key from
0001…3f; identity hashaca31af0441d81dbec71e82da0b4b5f5. - VEC-ID-SIGN (frozen): Ed25519 signature
bbfdcde5aa05197f…,validatetrue. - VEC-ID-TOKEN (frozen-injection,
Identity.py:827-928): 112-byte tokenefd9ec3449e46df2…= ephemeral_pub(32) || IV(16) || ciphertext || HMAC(32);decrypt(token) == plaintext.
Destination
- VEC-DEST-HASH (frozen): app
test, aspectvec→ name hash9da53eec82a28ce2f2e9(10), destination hash07d4541d4fdc0abfacc9364fdf979ee1(16).
Packet
- VEC-PKT-PLAIN (frozen):
0800fc0910664040482cd653166c8f225520006869— flags08(PLAIN/DATA), hops00, dest(16), context00, data"hi". - VEC-PKT-ENC (frozen-injection): SINGLE encrypted HEADER_1 packet, flags
00. - VEC-PKT-HEADER2 (computed,
Packet.py:255-259):4000 a0..af b0..bf 00 64617461— flags40(HEADER_2), transport id(16), dest(16), context, data.
Announce
- VEC-ANN-NORATCHET (frozen-injection): flags
01(context flag 0); 150 data bytes79a631eede1bf9c9…= pubkey(64) || name(10) || random(10) || sig(64) || app_data(2);validate_announcetrue. - VEC-ANN-RATCHET (frozen-injection): context flag 1; 182 data bytes including
the 32-byte ratchet;
validate_announcetrue.
Link
- VEC-LINK (frozen-injection,
Link.py:308-366): request dataa4e09292…= eph_x25519(32) || eph_ed25519(32) || signalling(3); link id4725ac1375601d182afec3610f019b25; ECDH agreement true; 64-byte session key569ac51a07fb242f…=hkdf(64, ecdh, salt=link_id).
Resource
- VEC-RES-ADV (computed,
Resource.py:1278-1355): advertisement dict with flags03(compressed+encrypted), packs to 146 bytes. - VEC-RES-PROOF (frozen,
Resource.py:755-756):proof_data = resource_hash || full_hash(data || resource_hash)=d257b38bc5d6aa22…(64 bytes).
Channel and Buffer
- VEC-CHAN-ENVELOPE (frozen):
abcd0007000b6368616e6e656c64617461— typeabcd, sequence0007, length000b, payload"channeldata". - VEC-STREAM-HDR (frozen):
810273747265616d64617461— header8102(eof set over stream id 0x0102), data"streamdata".
Transport
- VEC-PATH-REQUEST (frozen-injection,
Transport.py:2780-2787): payload000102…0f(target 16) || tag(16), in a PLAIN packet (flags08).
Framing and IFAC
- VEC-HDLC (frozen,
TCPInterface.py:44-52):01 7E 02 7D 03frames to7e017d5e027d5d037e. - VEC-IFAC (frozen,
Transport.py:1051-1087): tag2cf485c1dfcea002, masked output9f4a2cf485c1dfcea0…, IFAC header flag set, mask/unmask roundtrip recovers the original packet and tag.
Reticulum reference symbol inventory (frozen)
Frozen ground-truth enumeration of the wire surfaces in the vendored Python RNS reference. The specification is written against it; the Coverage ledger maps every entry to a section and a proof. To refresh, re-enumerate the source at the pinned commit and diff; a new unclassified symbol is a coverage gap.
Pin
| Component | Version | Submodule commit |
|---|---|---|
Reticulum (vendor/Reticulum) | RNS 1.3.5 | d5e62d4e15c5fe2e170f7bd9e120551671f21a27 |
Classification key
- N normative: crosses the wire or is observable by a peer; specified exactly and proven.
- I informative: internal behaviour an implementation may diverge on.
- X out of scope: interface drivers beyond framing, daemon/CLI, scaffolding.
Reticulum.py — system constants
| Symbol | Value | Line | Class |
|---|---|---|---|
MTU | 500 | 93 | N |
MDU | 464 | 152 | N |
TRUNCATED_HASHLENGTH | 128 | 145 | N |
HEADER_MINSIZE | 19 | 147 | N |
HEADER_MAXSIZE | 35 | 148 | N |
IFAC_MIN_SIZE | 1 | 149 | N |
IFAC_SALT | 32-byte hex | 150 | N |
Cryptography/*
| File | Symbol / method | Line | Class |
|---|---|---|---|
| Token.py | Token (modified Fernet), TOKEN_OVERHEAD=48, encrypt, decrypt, verify_hmac | 50,77,87,100 | N |
| X25519.py | X25519PrivateKey/PublicKey, generate, exchange | 126,139 | N |
| Ed25519.py | Ed25519PrivateKey/PublicKey, sign, verify | 53,69 | N |
| AES.py | AES_128_CBC, AES_256_CBC (16-byte IV, PKCS7) | — | N |
| HMAC.py | RFC 2104 HMAC-SHA256, new, digest | — | N |
| HKDF.py | hkdf(length, derive_from, salt, context) | 35 | N |
| PKCS7.py | block size 16 padding | — | N |
Identity.py (980 lines) — class Identity
Constants
| Symbol | Value | Line | Class |
|---|---|---|---|
KEYSIZE | 512 | 59 | N |
RATCHETSIZE | 256 | 64 | N |
RATCHET_EXPIRY | 2592000 | 69 | I |
TOKEN_OVERHEAD | 48 | 77 | N |
HASHLENGTH | 256 | 80 | N |
SIGLENGTH | 512 | 81 | N |
NAME_HASH_LENGTH | 80 | 83 | N |
TRUNCATED_HASHLENGTH | 128 | 84 | N |
DERIVED_KEY_LENGTH | 64 | 90 | N |
Methods
| Method | Line | Class |
|---|---|---|
full_hash / truncated_hash | 373/383 | N |
get_random_hash | 393 | N |
_get_ratchet_id / _ratchet_public_bytes / _generate_ratchet | 417-425 | N |
validate_announce | 532 | N |
encrypt / decrypt | 827/872 | N |
sign / validate | 931/948 | N |
update_hashes / get_public_key / get_private_key | 808/757/750 | N |
recall / remember | — | I (resolution) |
Destination.py (680 lines) — class Destination
Constants
| Symbol | Value | Line | Class |
|---|---|---|---|
SINGLE/GROUP/PLAIN/LINK | 0x00-0x03 | 63-66 | N |
PROVE_NONE/APP/ALL | 0x21-0x23 | 69-71 | N |
IN/OUT | 0x11/0x12 | 79-80 | N |
RATCHET_COUNT | 512 | 85 | I |
RATCHET_INTERVAL | 1800 | 90 | I |
PR_TAG_WINDOW | 30 | 83 | I |
Methods
| Method | Line | Class |
|---|---|---|
expand_name | 96 | N |
hash / hash_from_name_and_identity | 116/141 | N |
announce | 243 | N |
encrypt / decrypt | 585/611 | N |
Packet.py (603 lines) — class Packet, PacketReceipt
Constants
| Symbol | Value | Line | Class |
|---|---|---|---|
packet types DATA/ANNOUNCE/LINKREQUEST/PROOF | 0x00-0x03 | 60-63 | N |
header types HEADER_1/HEADER_2 | 0x00/0x01 | 67-68 | N |
context bytes NONE..LRPROOF | 0x00-0xFF | 72-92 | N |
FLAG_SET/FLAG_UNSET | 0x01/0x00 | 95-96 | N |
ENCRYPTED_MDU | 383 | 106 | N |
PLAIN_MDU | =MDU (464) | 110 | N |
PacketReceipt FAILED/SENT/DELIVERED/CULLED | 0,1,2,0xFF | 408-415 | I |
EXPL_LENGTH/IMPL_LENGTH | 96/64 | — | N (proofs) |
Methods
| Method | Line | Class |
|---|---|---|
get_packed_flags | 169 | N |
pack / unpack | 177/242 | N |
get_hashable_part | 355 | N |
validate_proof_packet / validate_proof | 443/498 | N |
Link.py (1538 lines) — class Link
Constants
| Symbol | Value | Line | Class |
|---|---|---|---|
ECPUBSIZE | 64 | — | N |
KEYSIZE | 32 | — | N |
LINK_MTU_SIZE | 3 | — | N |
MTU_BYTEMASK | 0x1FFFFF | — | N |
MODE_BYTEMASK | 0xE0 | — | N |
MODE_AES256_CBC | 0x01 | — | N |
states PENDING..CLOSED | 0x00-0x04 | — | I |
KEEPALIVE / STALE_TIME | 360/720 | — | I |
Methods
| Method | Line | Class |
|---|---|---|
link_id_from_lr_packet / set_link_id | 340/349 | N |
handshake | 353 | N |
prove / validate_proof | 371/396 | N |
signalling_bytes / mtu_from_lp_packet / mode_from_lp_packet | 148+ | N |
identify | 459 | N |
send_keepalive | 848 | N |
get_salt / get_context | 643/646 | N |
| watchdog, RTT scheduling, teardown | — | I |
Context-byte payloads (N)
LRPROOF 0xFF (sig(64)+eph_pub(32)+signalling(3)), LRRTT 0xFE (msgpack float),
LINKIDENTIFY 0xFB (pub(32)+sig(64)), LINKCLOSE 0xFC (link_id), KEEPALIVE
0xFA (single byte 0xFF), REQUEST/RESPONSE 0x09/0x0A.
Resource.py (1380 lines) — Resource, ResourceAdvertisement
Constants
| Symbol | Value | Line | Class |
|---|---|---|---|
MAPHASH_LEN | 4 | — | N |
RANDOM_HASH_SIZE | 4 | — | N |
HASHMAP_IS_EXHAUSTED/NOT | 0xFF/0x00 | — | N |
WINDOW*, *_TIMEOUT_FACTOR, MAX_RETRIES | various | — | I |
OVERHEAD (advertisement) | 134 | 1235 | N |
status NONE..CORRUPT | 0x00-0x08 | — | I |
Methods
| Method | Line | Class |
|---|---|---|
ResourceAdvertisement.__init__ / pack / unpack | 1278/1333 | N |
hashmap_update / request / receive_part | — | N (wire) / I (scheduling) |
prove / validate_proof | 752/782 | N |
advertise / assemble / window adaptation | 508/672 | I |
Context bytes RESOURCE 0x01, RESOURCE_ADV 0x02, RESOURCE_REQ 0x03,
RESOURCE_HMU 0x04, RESOURCE_PRF 0x05, RESOURCE_ICL 0x06, RESOURCE_RCL
0x07 (Packet.py:73-79) — N.
Channel.py (738 lines) / Buffer.py (371 lines)
| Symbol | Line | Class |
|---|---|---|
Envelope.pack/unpack (>HHH type,seq,len) | 192/179 | N |
StreamDataMessage header (14-bit id, compressed, eof) | Buffer 80-92 | N |
SMT_STREAM_DATA 0xff00, STREAM_ID_MAX 0x3fff, OVERHEAD 8 | — | N |
MessageState, CEType enums | — | I |
| channel sequencing/retransmission | — | I |
Transport.py (3585 lines) — class Transport
Normative surfaces
| Symbol | Line | Class |
|---|---|---|
transmit (IFAC masking) | 1051 | N |
inbound (IFAC unmasking) | 1398 | N |
request_path (path request packet) | 2771 | N |
path_request_handler / path response (PATH_RESPONSE rebroadcast) | 2866/2943 | N |
Informative
PATHFINDER_*, AP_PATH_TIME, ROAMING_PATH_TIME, LOCAL_REBROADCASTS_MAX,
PATH_REQUEST_* (50-83); the path/announce/link/reverse/tunnel tables and their
IDX_* shapes (3547-3586); announce retransmission timing and jitter; dedup;
table culling in jobs (508). All I.
Out of scope (X)
Interface drivers under RNS/Interfaces/* beyond the HDLC/KISS framing
documented in Framing and IFAC; the daemon/CLI tooling;
shared-instance IPC.
LXMF Protocol Specification
This appendix is an exact, English specification of the LXMF messaging protocol,
derived from and proven against the vendored Python reference
(vendor/LXMF, v0.9.6, commit 8499729) running on Reticulum
(vendor/Reticulum, RNS 1.3.5, commit d5e62d4). It is the contract the
libreticulum lxmf crate is built and tested against.
LXMF (Lightweight Extensible Message Format) is the store-and-forward messaging layer of Reticulum. It defines how a message is structured, signed, encrypted, sized, and delivered (opportunistically as a single packet, directly over a link, via a propagation node, or on paper), plus the anti-spam stamp and ticket mechanisms. It moves opaque bytes over Reticulum primitives; it carries no media processing of its own.
How to read this specification
- Normative statements use RFC 2119 keywords (MUST, SHOULD, MAY) and specify
behaviour an interoperable implementation has to reproduce. Every normative
fact carries either a
file:linecitation into the reference, a derivation with the arithmetic shown, or a labelled test vector[VEC-...]. - Informative sections describe internal reference behaviour (router scheduling, queues, persistence) that an implementation MAY diverge from without breaking wire or semantic compatibility.
- Test vectors are the genuine byte output of the reference, regenerated by
vectors/gen_vectors.pyand pinned to the submodule commits. A citation proves “the code says this”; a vector proves “these are the bytes”.
Sections
- Introduction and scope
- Cryptographic primitives
- Identifiers and sizes
- Message binary format
- Fields
- Delivery methods and sizing
- On-air sequences
- Stamps and proof-of-work
- Tickets
- Announce application data
- Propagation
- Router internals (informative)
- Constants reference
- Coverage ledger
- Test vectors
- Symbol inventory (frozen)
Introduction and scope
Reference
This specification describes LXMF as implemented by the pinned reference:
| Component | Version | Commit |
|---|---|---|
| LXMF | 0.9.6 (_version.py:1) | 8499729024a4cddfceb47ca07188bb5b1d11d179 |
| Reticulum (RNS) | 1.3.5 | d5e62d4e15c5fe2e170f7bd9e120551671f21a27 |
APP_NAME is "lxmf" (LXMF.py:1). Where the reference defers to a Reticulum
primitive (hashing, signing, encryption, MDU sizes), this document cites
vendor/Reticulum and does not re-specify the primitive; its behaviour-as-used
is pinned by test vectors instead.
Scope
Normative (this document specifies exactly, and proves):
- the message binary layout, hashing input, signing input, and verification;
- the message payload msgpack structure and its type discipline;
- the fields dictionary and its identifiers;
- delivery method selection and the size thresholds that drive it;
- the on-air form of each delivery method (opportunistic, direct, propagated, paper);
- stamp construction, validity, value, and the ticket shortcut;
- announce application-data formats (delivery and propagation);
- the client-facing propagation wire surfaces (
/offer,/get, transient ingest, error codes).
Informative (described, not byte-proven; an implementation MAY diverge):
- router job scheduling, outbound/inbound queue management, retry cadences and timeouts;
- on-disk persistence layout (message store, peers, tickets, costs, stats);
- propagation-node peer selection, rotation, and sync scheduling internals.
Out of scope: the lxmd daemon and CLI (Utilities/lxmd.py).
The full enumeration of reference symbols and their normative / informative / out-of-scope classification is the frozen Symbol inventory. The Coverage ledger maps every normative symbol to a section and a proof; a normative symbol with no mapping is a coverage gap.
Notational conventions
- RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY) carry their usual meaning and mark normative requirements.
- Citations take the form
(LXMessage.py:364)and refer to the pinned reference file undervendor/LXMF/LXMF/unless another path is given. - Byte layouts are shown as offset tables or annotated hex. Concatenation is
written
a || b. A field width in bytes is shown asname(16). - Test vectors are referenced by label, e.g.
[VEC-MSG-1], and are listed in full in Test vectors. They live in machine-readable form invectors/vectors.json. - Hashes are SHA-256 unless stated. Integers in stamp arithmetic are
big-endian (
LXStamper.py:35,45).
Regenerating the vectors
From the repository root:
PYTHONPATH=vendor/Reticulum:vendor/LXMF \
python3 docs/src/appendix/lxmf/vectors/gen_vectors.py
The harness fixes all identity key material and the message timestamp, runs the
genuine reference code, asserts determinism for every frozen vector, and writes
vectors/vectors.json with the submodule commits recorded in its meta block.
Re-running MUST reproduce the committed file byte for byte. Vectors whose output
depends on ephemeral encryption key material are marked roundtrip and are
proven by a decrypt round trip plus structural assertions rather than by frozen
ciphertext.
Cryptographic primitives
LXMF builds on Reticulum primitives for all hashing, signing, and encryption. An implementation MUST use primitives that produce byte-identical results to these, because their outputs are signed, hashed, and exchanged on the wire.
Hashing
full_hash(x)is SHA-256 overx, 32 bytes (RNS.Identity.HASHLENGTH= 256 bits). LXMF uses it for the message hash and message-id (LXMessage.py:365-366), the transient-id (LXMessage.py:431), and the stamp digest (LXStamper.py:34,44).truncated_hash(x)is the leading 16 bytes offull_hash(x)(RNS.Identity.TRUNCATED_HASHLENGTH= 128 bits). LXMF uses it for the ticket stamp shortcut (LXMessage.py:274,297).
Signing
Identity.sign(m)is Ed25519 overm, producing a 64-byte signature (RNS.Identity.SIGLENGTH= 512 bits). Ed25519 is deterministic (RFC 8032), so a given key and message always yield the same signature;[VEC-MSG-1]pins one.Identity.validate(sig, m)verifies an Ed25519 signature. The inbound path calls it assource.identity.validate(signature, signed_part)(LXMessage.py:794).
Encryption
Destination.encrypt(plaintext)encrypts to a SINGLE destination using Reticulum’s ECDH scheme: a fresh ephemeral X25519 key per call, an HKDF-derived AES-128-CBC key, and an HMAC token, optionally keyed by the destination’s current ratchet. Because the ephemeral key is fresh per call, the ciphertext is not reproducible across runs; LXMF vectors that involve encryption ([VEC-PROP-ENVELOPE],[VEC-PAPER-URI]) are proven by a decrypt round trip, not by frozen ciphertext.- LXMF calls
encryptfor the propagated and paper forms over the message tailpacked[16:](LXMessage.py:427,446), andDestination.decrypton receipt. - The encryption description strings
"AES-128"/"Curve25519"/"Unencrypted"(LXMessage.py:97-99) are local labels only, not on the wire.
Key derivation
Cryptography.hkdf(length, derive_from, salt, context)is HKDF-SHA-256. LXMF uses it only to build the stamp workblock (LXStamper.py:22-25); see Stamps and proof-of-work.
Identities
An Identity carries an X25519 key pair (encryption) and an Ed25519 key pair
(signing). The reference constructs deterministic identities for the vectors from
fixed 64-byte private material X25519(32) || Ed25519(32)
(gen_vectors.py, recorded in vectors.json meta.src_identity_prv_hex /
meta.dst_identity_prv_hex). An identity’s 16-byte hash is truncated_hash of
its concatenated public keys; LXMF treats the hash as opaque and obtains it from
the Reticulum Destination.
Identifiers and sizes
All sizes below are the genuine class attributes of the reference, captured in
vectors.json constants.
Identifiers
| Identifier | Width | Definition | Citation |
|---|---|---|---|
| Destination hash | 16 | Reticulum SINGLE destination hash of lxmf/delivery | LXMessage.py:39 |
| Source hash | 16 | sender’s lxmf/delivery destination hash | LXMessage.py:380-381 |
| Signature | 64 | Ed25519 over the signed part | LXMessage.py:40 |
| Message hash / message-id | 32 | `full_hash(dest | |
| Transient-id | 32 | full_hash(lxmf_data) for propagation | LXMessage.py:431 |
| Stamp | 32 | proof-of-work nonce | LXStamper.py:13 |
| Ticket | 16 | shared secret for stamp shortcut | LXMessage.py:41 |
The message hash and message-id are the same value (LXMessage.py:366); this
document uses “message-id”. [VEC-MSG-1] shows message_id_hex == hash_hex.
Overhead and packet sizes
The fixed overhead and the three single-packet content limits are derived constants. The derivations (MUST evaluate to these values):
TIMESTAMP_SIZE = 8 (LXMessage.py:60)
STRUCT_OVERHEAD = 8 (LXMessage.py:61)
LXMF_OVERHEAD = 2*16 + 64 + 8 + 8 = 112 (LXMessage.py:62)
ENCRYPTED_PACKET_MDU = RNS.Packet.ENCRYPTED_MDU + 8 = 391 (LXMessage.py:67)
ENCRYPTED_PACKET_MAX_CONTENT = 391 - 112 + 16 = 295 (LXMessage.py:78)
LINK_PACKET_MDU = RNS.Link.MDU = 431 (LXMessage.py:83)
LINK_PACKET_MAX_CONTENT = 431 - 112 = 319 (LXMessage.py:89)
PLAIN_PACKET_MDU = RNS.Packet.PLAIN_MDU = 464 (LXMessage.py:93)
PLAIN_PACKET_MAX_CONTENT = 464 - 112 + 16 = 368 (LXMessage.py:94)
PAPER_MDU = ((2953 - (3 + 3)) * 6) // 8 = 2210 (LXMessage.py:105)
QR_MAX_STORAGE = 2953 (LXMessage.py:104); the + 16 terms restore the
destination hash that is excluded from LXMF_OVERHEAD accounting for the
encrypted single-packet forms. These are the thresholds the delivery-method
selector compares against; see Delivery methods and sizing.
Content size
The reference defines the content size of a packed message as
content_size = len(packed_payload) - TIMESTAMP_SIZE - STRUCT_OVERHEAD
= len(packed_payload) - 16
(LXMessage.py:385). This is the value compared against the limits above. Note
it is computed from the serialized payload length, not from the raw content
bytes, so msgpack framing and the title and fields count toward it.
Message binary format
This section is normative and is proven by [VEC-MSG-1], [VEC-MSG-2], and
[VEC-MSG-3].
Packed layout
A packed LXMF message is the concatenation (LXMessage.py:379-383):
destination_hash(16) || source_hash(16) || signature(64) || packed_payload
offset 0 16 32 96
An implementation MUST produce exactly this layout. packed_payload is the
msgpack serialization of the payload array (below). The total fixed prefix is 96
bytes.
Payload array
The payload is a msgpack array (LXMessage.py:359):
[ timestamp, title, content, fields ]
with an optional fifth element stamp appended when a stamp is generated
(LXMessage.py:368-370); see Stamps.
The msgpack type discipline is normative and is a common interop trap:
| Element | msgpack type | Citation |
|---|---|---|
timestamp | float64 (f64), seconds since the Unix epoch | LXMessage.py:354,359 |
title | binary (bin), not string | LXMessage.py:190-193 |
content | binary (bin), not string | LXMessage.py:199-202 |
fields | map, integer keys (may be empty {}) | LXMessage.py:212-216 |
stamp (optional) | binary (bin), 32 bytes | LXMessage.py:370 |
title and content are stored and packed as bytes; the *_as_string
accessors only decode UTF-8 on demand (LXMessage.py:196,205). An implementation
MUST pack them as msgpack bin, never str. Mismatching this changes the
serialized bytes and therefore the message-id, so a Python peer rejects the
message.
Proof: annotated [VEC-MSG-1] payload
For timestamp = 1700000000.0, title = b"Hi", content = b"Hello",
fields = {}, the packed payload is 94cb41d954fc40000000c4024869c40548656c6c6f80:
94 fixarray, 4 elements
cb 41d954fc40000000 float64 = 1700000000.0 (timestamp)
c4 02 4869 bin8 len 2 = "Hi" (title)
c4 05 48656c6c6f bin8 len 5 = "Hello" (content)
80 fixmap, 0 entries = {} (fields)
The cb (float64), c4 (bin8), and 80 (fixmap) prefixes prove the type
discipline directly. [VEC-MSG-2] shows a non-empty fields map carrying an
integer key.
Hashing input (message-id)
The message hash is (LXMessage.py:361-366):
hashed_part = destination_hash || source_hash || msgpack(payload_without_stamp)
message_id = full_hash(hashed_part)
The payload hashed here MUST NOT include the optional stamp element. On unpack
the reference strips a present stamp before re-hashing (LXMessage.py:744-747).
[VEC-MSG-1] records hashed_part_hex and the resulting message_id_hex.
Signing input
The signature is (LXMessage.py:372-375):
signed_part = hashed_part || message_id (= dest || src || msgpack(payload) || message_id)
signature = source.sign(signed_part) (Ed25519, 64 bytes)
[VEC-MSG-1] records signed_part_hex, signature_hex, and
signature_valid = true (verified with source.identity.validate).
Unpack and verification
unpack_from_bytes (LXMessage.py:735-807) slices at the fixed offsets:
destination_hash = bytes[0:16], source_hash = bytes[16:32],
signature = bytes[32:96], packed_payload = bytes[96:]. It unpacks the
payload, and if the array has more than four elements treats element [4] as the
stamp and removes it before recomputing the hash (LXMessage.py:741-747).
Verification requires the source identity to be known (learned from its
announce). The outcome is one of (LXMessage.py:790-801):
- signature valid:
signature_validated = true; - signature present but invalid:
unverified_reason = SIGNATURE_INVALID (0x02); - source identity unknown:
unverified_reason = SOURCE_UNKNOWN (0x01).
[VEC-MSG-3] makes the source identity recallable, unpacks [VEC-MSG-1], and
records signature_validated = true, matches_source = true, and the recovered
title and content. An implementation MUST reproduce these offsets and the
stamp-stripping rule, or it will compute a different message-id than the sender.
Fields
The fields element of the payload (LXMessage.py:359) is a msgpack map with
integer keys. Keys are the FIELD_* identifiers; values are field-specific. An
empty map {} is valid and is the default. Field keys are packed as msgpack
integers, including the high-value debug keys which serialize as uint8.
Field identifiers (LXMF.py:8-41)
| Key | Name | Value convention |
|---|---|---|
| 0x01 | FIELD_EMBEDDED_LXMS | list of embedded LXM byte strings |
| 0x02 | FIELD_TELEMETRY | telemetry blob |
| 0x03 | FIELD_TELEMETRY_STREAM | telemetry stream blob |
| 0x04 | FIELD_ICON_APPEARANCE | appearance descriptor |
| 0x05 | FIELD_FILE_ATTACHMENTS | list of [name, bytes] |
| 0x06 | FIELD_IMAGE | [format, bytes] |
| 0x07 | FIELD_AUDIO | [audio_mode, bytes] (see audio modes) |
| 0x08 | FIELD_THREAD | thread reference |
| 0x09 | FIELD_COMMANDS | list of commands |
| 0x0A | FIELD_RESULTS | list of results |
| 0x0B | FIELD_GROUP | group metadata |
| 0x0C | FIELD_TICKET | [expires, ticket], see Tickets |
| 0x0D | FIELD_EVENT | event payload |
| 0x0E | FIELD_RNR_REFS | RNR references |
| 0x0F | FIELD_RENDERER | renderer hint (see renderers) |
| 0xFB | FIELD_CUSTOM_TYPE | custom type tag |
| 0xFC | FIELD_CUSTOM_DATA | custom data |
| 0xFD | FIELD_CUSTOM_META | custom metadata |
| 0xFE | FIELD_NON_SPECIFIC | unspecified |
| 0xFF | FIELD_DEBUG | debug payload |
An implementation MUST treat unknown field keys as opaque and preserve them
(the reference round-trips the whole fields map through msgpack). [VEC-MSG-2]
carries {0x0F: 0x02} (FIELD_RENDERER: RENDERER_MARKDOWN) and shows it packed
inside the payload.
Renderers (LXMF.py:89-92)
| Value | Name |
|---|---|
| 0x00 | RENDERER_PLAIN |
| 0x01 | RENDERER_MICRON |
| 0x02 | RENDERER_MARKDOWN |
| 0x03 | RENDERER_BBCODE |
Audio modes (LXMF.py:55-79)
Used as the first element of FIELD_AUDIO. Codec2 modes AM_CODEC2_450PWB
(0x01) through AM_CODEC2_3200 (0x09); Opus modes AM_OPUS_OGG (0x10) through
AM_OPUS_LOSSLESS (0x19); AM_CUSTOM (0xFF). These identify the audio codec and
profile of an attached clip; LXMF does not process the audio, it only carries the
mode byte and the encoded bytes.
Delivery methods and sizing
Method and representation constants
| Method | Value | Citation |
|---|---|---|
OPPORTUNISTIC | 0x01 | LXMessage.py:29 |
DIRECT | 0x02 | LXMessage.py:30 |
PROPAGATED | 0x03 | LXMessage.py:31 |
PAPER | 0x05 | LXMessage.py:32 |
| Representation | Value | Citation |
|---|---|---|
UNKNOWN | 0x00 | LXMessage.py:24 |
PACKET | 0x01 | LXMessage.py:25 |
RESOURCE | 0x02 | LXMessage.py:26 |
The representation records whether the message goes out as a single Reticulum Packet or as a Reticulum Resource (multi-packet transfer). PAPER uses neither.
Selection algorithm
pack() sets method and representation from desired_method and the content
size (LXMessage.py:387-455). The normative rules:
- If no method is desired, default to
DIRECT(LXMessage.py:389-390). - OPPORTUNISTIC is valid only for SINGLE or PLAIN destinations. If the
content size exceeds
ENCRYPTED_PACKET_MAX_CONTENT(295) for a SINGLE destination, the reference falls back toDIRECT(LXMessage.py:394-398). Otherwise representation isPACKET(LXMessage.py:401-412). For PLAIN destinations the limit isPLAIN_PACKET_MAX_CONTENT(368). - DIRECT: if content size
<= LINK_PACKET_MAX_CONTENT(319), representation isPACKET; otherwiseRESOURCE(LXMessage.py:414-421). - PROPAGATED: the message is wrapped into the propagation envelope (see
Propagation); if the envelope size
<= LINK_PACKET_MAX_CONTENTit is aPACKET, otherwise aRESOURCE(LXMessage.py:423-441). - PAPER: if the encrypted paper form
<= PAPER_MDU(2210) the representation is paper; otherwisepack()raises (LXMessage.py:443-455).
content_size is the serialized-payload measure from
Identifiers and sizes (LXMessage.py:385).
An implementation MUST apply the same thresholds so that a message a Python peer would send as a single packet is not sent as a resource (and vice versa), since the on-air framing differs.
On-air forms
| Method | Representation | On-air bytes | Citation |
|---|---|---|---|
| OPPORTUNISTIC | PACKET | packed[16:] (destination hash omitted) | LXMessage.py:631 |
| DIRECT | PACKET | full packed over a Link | LXMessage.py:633 |
| DIRECT | RESOURCE | full packed as a Resource over a Link | LXMessage.py:650-651 |
| PROPAGATED | PACKET/RESOURCE | propagation_packed to a propagation node | LXMessage.py:634-635,652-653 |
| PAPER | (paper) | lxm:// URI or QR | LXMessage.py:687-702 |
The opportunistic packet omits the leading 16-byte destination hash because the
Reticulum packet header already addresses the destination; the receiver
reconstructs the full message by prepending the known destination hash. This is
proven by [VEC-DLV-OPP] (on_air_hex == packed[16:]) and the direct full-bytes
form by [VEC-DLV-DIRECT].
On-air sequences
This section describes the on-air event sequence for each delivery method. The
bytes placed on the wire are normative (see
Delivery methods and sizing); the scheduling
(retry cadence, timeouts, path-request timing) is informative and lives in
Router internals. The relevant cadence constants are
MAX_DELIVERY_ATTEMPTS = 5 (LXMRouter.py:30), DELIVERY_RETRY_WAIT = 10 s
(LXMRouter.py:32), PATH_REQUEST_WAIT = 7 s (LXMRouter.py:33), and
MAX_PATHLESS_TRIES = 1 (LXMRouter.py:34).
Opportunistic
- If there is no path to the destination, request one and wait (informative
cadence). After
MAX_PATHLESS_TRIESthe message may be sent pathless. - Send a single Reticulum Packet whose payload is
packed[16:](the destination hash is omitted;LXMessage.py:631). - The message state becomes
SENT. Delivery is confirmed by a Reticulum proof; on timeout the router re-queues up toMAX_DELIVERY_ATTEMPTS.
No link is established. Suitable only for messages within the single-packet content limit.
Direct
- Ensure a path, then establish a Reticulum
Linkto the destination’slxmf/deliveryendpoint. - When the link is
ACTIVE(LXMessage.py:647):- if representation is
PACKET, send one Packet carrying the fullpackedbytes over the link (LXMessage.py:633); - if representation is
RESOURCE, transferpackedas a Reticulum Resource over the link (LXMessage.py:650-651), with compression negotiated per the peer’s advertised support.
- if representation is
- On link failure before delivery, tear down and retry.
Propagated
- Establish a
Linkto the configured outbound propagation node. - Send
propagation_packed(the encrypted envelope, see Propagation) as a Packet or Resource depending on size (LXMessage.py:634-635,652-653). - Success marks the message
SENT(notDELIVERED): final delivery to the recipient happens asynchronously when the recipient syncs from the node.
Paper
No Reticulum transport. pack() produces the encrypted paper form; as_uri()
renders it as an lxm:// URI (LXMessage.py:687-702) or as_qr() as a QR code.
The recipient ingests the URI out of band.
State model (informative)
A message moves through the states GENERATING (0x00) -> OUTBOUND (0x01) -> SENDING (0x02) -> SENT (0x04) -> DELIVERED (0x08), with terminal REJECTED (0xFD), CANCELLED (0xFE), and FAILED (0xFF) (LXMessage.py:14-21). These
are local lifecycle states, not on-wire values, and an implementation MAY model
the lifecycle differently.
Stamps and proof-of-work
Stamps are an anti-spam proof-of-work bound to a message-id (delivery stamps) or
a transient-id (propagation stamps). This section is normative and is proven by
[VEC-STAMP-1]. An implementation MUST reproduce the workblock, validity test,
and value computation bit-for-bit, or its stamps will not be accepted by a Python
peer (and vice versa).
Workblock
stamp_workblock(material, expand_rounds):
workblock = b""
for n in range(expand_rounds):
workblock += hkdf(length=256,
derive_from=material,
salt=full_hash(material || msgpack(n)),
context=None)
return workblock
(LXStamper.py:18-29). Each round appends 256 bytes, so the workblock is
expand_rounds * 256 bytes. The salt for round n is
full_hash(material || msgpack(n)), where msgpack(n) is the msgpack encoding of
the integer n (LXStamper.py:24). The expand-round counts are:
| Context | Rounds | Workblock size | Citation |
|---|---|---|---|
| Delivery stamp | WORKBLOCK_EXPAND_ROUNDS = 3000 | 768 000 B | LXStamper.py:10 |
| Propagation stamp | WORKBLOCK_EXPAND_ROUNDS_PN = 1000 | 256 000 B | LXStamper.py:11 |
| Peering key | WORKBLOCK_EXPAND_ROUNDS_PEERING = 25 | 6 400 B | LXStamper.py:12 |
The 768 kB delivery workblock is held in RAM during both generation and validation. On constrained targets this is significant; see the
no_stddiscussion in the feasibility report.
Validity
stamp_valid(stamp, target_cost, workblock):
target = 1 << (256 - target_cost)
return int.from_bytes(full_hash(workblock || stamp), "big") <= target
(LXStamper.py:42-46). The digest is interpreted as a big-endian 256-bit
integer and compared against target. target_cost is the number of required
leading zero bits. The stamp itself is 32 random bytes (STAMP_SIZE,
LXStamper.py:13).
Value
stamp_value(workblock, stamp):
count leading zero bits of full_hash(workblock || stamp) # big-endian
(LXStamper.py:31-40). The value is the achieved number of leading zero bits.
Proof: [VEC-STAMP-1]
For a fixed 32-byte material, expand_rounds = 4, and target_cost = 8, the
harness builds the workblock (1024 bytes = 4 x 256), then deterministically
searches stamp = full_hash(material || counter_be8) over increasing counter
until stamp_valid holds. The vector records the winning counter, the stamp, the
digest, the target (0x0100…00, i.e. 1 << 248, one set bit then 248 zero
bits), valid = true, and stamp_value = 8. The reduced round count keeps the vector cheap to reproduce;
the algorithm it pins is identical to the production path, which differs only
in expand_rounds.
Generation
generate_stamp(material, stamp_cost, expand_rounds) brute-forces random 32-byte
stamps until stamp_valid (LXStamper.py:92-111). The reference parallelizes
this across processes on Linux and falls back to single-process elsewhere
(LXStamper.py:145-354); the parallelism is informative, the resulting stamp is
not.
Where stamps are required
- Delivery stamp: the recipient advertises a
stamp_costin its delivery announce (see Announce application data). The sender generates a stamp over the message-id and appends it as payload element[4](LXMessage.py:368-370,317). The recipient validates it withvalidate_stamp(LXMessage.py:270-291). - Propagation stamp: generated over the transient-id with
WORKBLOCK_EXPAND_ROUNDS_PNand the node’s advertised cost (LXMessage.py:326-350). - Ticket shortcut: if a valid ticket is held, the stamp is
truncated_hash(ticket || message_id)and the value isCOST_TICKET = 256, bypassing proof-of-work (LXMessage.py:274-277,296-300). See Tickets.
Validation order
validate_stamp(target_cost, tickets) first tries each held inbound ticket: if
stamp == truncated_hash(ticket || message_id) the stamp is accepted with value
COST_TICKET (LXMessage.py:271-277). Otherwise it builds the workblock over the
message-id and runs stamp_valid (LXMessage.py:284-289). An implementation
MUST check tickets before proof-of-work to interoperate with ticketed senders.
Tickets
A ticket is a 16-byte shared secret (TICKET_LENGTH, LXMessage.py:41) that lets
a known correspondent skip proof-of-work. The recipient issues a ticket to a
sender; the sender then derives stamps from it cheaply.
Derivation
A ticketed stamp is (LXMessage.py:297, validated at :274):
stamp = truncated_hash(ticket || message_id)
with value COST_TICKET = 256 (LXMessage.py:52,298). On the receiving side,
validate_stamp accepts the message if stamp equals
truncated_hash(ticket || message_id) for any held inbound ticket
(LXMessage.py:271-277). An implementation MUST use truncated_hash (16 bytes),
matching the stamp width expectation of this path.
Issuing
generate_ticket(destination_hash, expiry) (LXMRouter.py:1025-1052) returns
[expires, ticket] where:
ticket = os.urandom(16)(LXMRouter.py:1048);expires = now + TICKET_EXPIRY(LXMRouter.py:1047).
An existing inbound ticket with more than TICKET_RENEW validity left is reused
rather than reissued (LXMRouter.py:1039-1041), and a new ticket is not issued to
a destination more often than TICKET_INTERVAL (LXMRouter.py:1028-1033).
Exchange
A ticket is delivered to a correspondent inside a message via FIELD_TICKET
(0x0C, LXMF.py:19), carrying the [expires, ticket] pair. The receiver remembers
it as an outbound ticket (remember_ticket, LXMRouter.py:1054-1057) and uses it
for subsequent stamps until it expires (get_outbound_ticket,
LXMRouter.py:1059-1065).
Timing constants
| Constant | Value | Seconds | Citation |
|---|---|---|---|
TICKET_EXPIRY | 21 days | 1 814 400 | LXMessage.py:48 |
TICKET_GRACE | 5 days | 432 000 | LXMessage.py:49 |
TICKET_RENEW | 14 days | 1 209 600 | LXMessage.py:50 |
TICKET_INTERVAL | 1 day | 86 400 | LXMessage.py:51 |
The validity windows are part of the interoperable behaviour (a peer expects a
ticket to remain valid for TICKET_EXPIRY plus TICKET_GRACE); the exact reuse
and reissue scheduling around them is informative.
Announce application data
LXMF carries application data in Reticulum announces. There are two formats: the
delivery announce (sent by a normal LXMF destination) and the propagation-node
announce. Both are normative and proven by [VEC-ANN-DELIVERY] and
[VEC-ANN-PROPAGATION].
Delivery announce
The delivery announce app_data is (LXMRouter.py:990-1002):
msgpack([ display_name, stamp_cost ])
display_name: the UTF-8 encoded display name asbin, orNone(LXMRouter.py:991-992).stamp_cost: an integer in(0, 255), orNone(LXMRouter.py:994-997).
Format detection
The decoders distinguish this version-0.5.0+ format from the legacy format
(a bare UTF-8 display name) by sniffing the first byte: it is the new format iff
app_data[0] is in 0x90..0x9f (msgpack fixarray) or equals 0xdc (array16)
(LXMF.py:122,145). An implementation MUST emit a msgpack array so this sniff
succeeds; a two-element array begins with 0x92.
Proof: [VEC-ANN-DELIVERY]
msgpack([b"Alice", 8]) produces app_data with first_byte = 0x92. The genuine
decoders recover display_name = "Alice" (display_name_from_app_data,
LXMF.py:117-139) and stamp_cost = 8 (stamp_cost_from_app_data,
LXMF.py:141-152).
Propagation-node announce
The propagation announce app_data is a 7-element list
(LXMRouter.py:307-319):
msgpack([
legacy_flag, # 0: bool, legacy LXMF PN support
timebase, # 1: int, int(time.time())
propagation_enabled, # 2: bool
per_transfer_limit_kb, # 3: int
per_sync_limit_kb, # 4: int
[prop_cost, prop_flex, peering_cost], # 5: list of three ints
metadata, # 6: dict (PN_META_* keys)
])
Validity
pn_announce_data_is_valid (LXMF.py:191-217) requires: data decodes to a
list of length >= 7; data[1] (timebase), data[3], data[4] are integer-
coercible; data[2] is strictly True or False; data[5] is a list whose
first three elements are integer-coercible; and data[6] is a dict. An
implementation MUST satisfy all of these for its propagation announce to be
accepted.
Metadata map (LXMF.py:98-104)
Keys: PN_META_VERSION (0x00), PN_META_NAME (0x01), PN_META_SYNC_STRATUM
(0x02), PN_META_SYNC_THROTTLE (0x03), PN_META_AUTH_BAND (0x04),
PN_META_UTIL_PRESSURE (0x05), PN_META_CUSTOM (0xFF). The node name is
metadata[PN_META_NAME] as UTF-8 bytes (LXMRouter.py:304).
Proof: [VEC-ANN-PROPAGATION]
The vector builds the 7-element list with metadata = {PN_META_NAME: b"NodeA"},
stamp_costs = [16, 3, 18], and a fixed timebase, then proves with the genuine
helpers: pn_announce_data_is_valid = true, pn_name_from_app_data = "NodeA"
(LXMF.py:169-180), and pn_stamp_cost_from_app_data = 16 (LXMF.py:182-189).
Field 1 (timebase) is int(time.time()) in the real protocol and is pinned to a
constant in the vector.
Propagation
Propagation lets a sender deposit a message at an always-reachable node for an
offline recipient to collect later. This section specifies the normative
client-facing wire surfaces and proves the envelope with [VEC-PROP-ENVELOPE].
The node-internal store, peer selection, rotation, and sync scheduling are
informative (Router internals).
Propagation transfer envelope
A propagated message is wrapped as follows (LXMessage.py:423-433):
pn_encrypted_data = destination.encrypt(packed[16:])
lxmf_data = packed[:16] || pn_encrypted_data
transient_id = full_hash(lxmf_data)
if propagation_stamp: lxmf_data || = propagation_stamp # 32 bytes
propagation_packed = msgpack([ wall_clock_timestamp, [ lxmf_data ] ])
Normative points:
- The destination hash (
packed[:16]) stays in cleartext; the rest of the packed message (packed[16:], i.e. source hash, signature, payload) is encrypted to the recipient (LXMessage.py:427,430). transient_id = full_hash(lxmf_data)and is computed before any propagation stamp is appended (LXMessage.py:431-432).- The envelope is
msgpack([timestamp, [lxmf_data, ...]]): a timestamp followed by a list of one or morelxmf_datablobs (LXMessage.py:433). The peer-sync path reuses the same shape with many blobs (LXMPeer.py:462).
Proof: [VEC-PROP-ENVELOPE]
Because destination.encrypt uses a fresh ephemeral key per call, the ciphertext
is not reproducible; the vector is a round-trip proof. It records the inner
packed bytes, the cleartext dest_hash_prefix, the structure
destination_hash(16) || destination.encrypt(packed[16:]), the derived
transient_id, and proves destination.decrypt(pn_encrypted) == packed[16:]
(decrypt_recovers_inner_tail = true). An implementation MUST reproduce the
framing and the transient_id derivation; the ciphertext itself is
non-deterministic by construction.
Propagation-node announce
See Announce application data for the 7-element node announce that advertises the node’s limits and stamp costs.
/offer (push to a node)
A syncing party requests OFFER_REQUEST_PATH = "/offer" (LXMPeer.py:14) over a
Link with the payload (LXMPeer.py:381,385):
offer = [ peering_key, [ transient_id, ... ] ]
where peering_key is the party’s proof-of-work peering key (see below) and the
list is the transient-ids it offers. The node replies via offer_response
(LXMPeer.py:396); the reply is one of: False (node already has all),
True (node wants all), or a list (the subset the node wants). The wanted
messages are then pushed as one Resource carrying
msgpack([timestamp, [lxmf_data, ...]]) (LXMPeer.py:462-464).
/get (collect from a node)
A recipient requests MESSAGE_GET_PATH = "/get" (LXMPeer.py:15) with the
payload (LXMRouter.py:1427-1449):
[ want, have ]
- if both
wantandhaveareNone, the node returns a list of the recipient’s availabletransient_ids, sorted by size (LXMRouter.py:1436-1449); - otherwise
havelists transient-ids the client already holds (so the node can drop them) andwantlists the ones to send (LXMRouter.py:1451-).
Error codes (LXMPeer.py:24-31)
Returned by the node’s request handlers:
| Code | Name | Meaning |
|---|---|---|
| 0xF0 | ERROR_NO_IDENTITY | requester did not identify on the link |
| 0xF1 | ERROR_NO_ACCESS | requester not allowed |
| 0xF3 | ERROR_INVALID_KEY | invalid peering key |
| 0xF4 | ERROR_INVALID_DATA | malformed request |
| 0xF5 | ERROR_INVALID_STAMP | propagation stamp invalid |
| 0xF6 | ERROR_THROTTLED | rate limited (PN_STAMP_THROTTLE = 180 s) |
| 0xFD | ERROR_NOT_FOUND | requested message not found |
| 0xFE | ERROR_TIMEOUT | request timed out |
Peering key
A peering key is a proof-of-work over peer_identity_hash || node_identity_hash
with WORKBLOCK_EXPAND_ROUNDS_PEERING = 25 rounds against the node’s advertised
peering_cost (LXMPeer.py:242-265; validated by validate_peering_key,
LXStamper.py:48-51). It authorizes a party to offer messages to the node.
Transient ingest and expiry (informative)
A node stores each accepted message keyed by transient_id, validates its
propagation stamp in batches (LXStamper.py:87-90), and expires entries after
MESSAGE_EXPIRY = 30 days (LXMRouter.py:38). The on-disk layout and peer
bookkeeping are informative.
Router internals (informative)
Everything in this section is informative. It documents how the reference router behaves so an implementation can match observable timing and limits where useful, but an implementation MAY diverge from any of it without breaking wire or semantic compatibility. The normative obligations are in the preceding sections.
Delivery scheduling (LXMRouter.py:30-83)
| Constant | Value | Meaning |
|---|---|---|
MAX_DELIVERY_ATTEMPTS | 5 | retries before a message fails |
PROCESSING_INTERVAL | 4 s | jobloop tick |
DELIVERY_RETRY_WAIT | 10 s | wait between delivery attempts |
PATH_REQUEST_WAIT | 7 s | wait after a path request |
MAX_PATHLESS_TRIES | 1 | sends attempted before forcing a path request |
LINK_MAX_INACTIVITY | 600 s | idle link teardown |
P_LINK_MAX_INACTIVITY | 180 s | idle propagation link teardown |
Expiry and limits
| Constant | Value | Meaning |
|---|---|---|
MESSAGE_EXPIRY | 30 days | propagation store retention |
STAMP_COST_EXPIRY | 45 days | cached outbound stamp cost retention |
PROPAGATION_LIMIT | 256 KB | per-transfer propagation limit |
SYNC_LIMIT | 256*40 KB | per-sync cumulative limit |
DELIVERY_LIMIT | 1000 KB | direct delivery resource limit |
PN_STAMP_THROTTLE | 180 s | propagation stamp throttle window |
Peering (LXMRouter.py:43-60)
MAX_PEERS = 20, AUTOPEER = true, AUTOPEER_MAXDEPTH = 4 hops,
ROTATION_HEADROOM_PCT = 10, ROTATION_AR_MAX = 0.5, PEERING_COST = 18
(max 26), PROPAGATION_COST = 16 (min 13, flex 3). Peer selection and rotation
policy are internal.
Jobloop cadence (LXMRouter.py:853-860)
A single jobloop dispatches staggered jobs: outbound processing (1 s), deferred
stamp generation (1 s), link cleanup (1 s), transient-cache cleanup (60 s),
message-store cleanup (120 s), peer sync (6 s), peer rotation (56 * peer-ingest
interval). An implementation may use any scheduler.
Persistence
The reference persists to a writable storage path: the message store (one file
per propagation message, named transient_id_timestamp_stampvalue), peers,
available_tickets, outbound_stamp_costs, locally_delivered_transient_ids,
locally_processed_transient_ids, and node_stats. The on-disk encoding (mostly
msgpack) and file naming are implementation choices; only the messages and
announces these structures drive onto the wire are normative. In libreticulum
these map onto the Storage trait rather than a filesystem.
Sync state machine (LXMPeer.py:17-22)
A propagation peer progresses IDLE -> LINK_ESTABLISHING -> LINK_READY -> REQUEST_SENT -> RESPONSE_RECEIVED -> RESOURCE_TRANSFERRING -> IDLE, with
STRATEGY_LAZY/STRATEGY_PERSISTENT (default persistent) controlling whether a
peer keeps syncing while unhandled messages remain. The states are local; the
wire payloads they produce (/offer, the sync Resource) are normative
(Propagation).
Constants reference
Every constant in the Symbol inventory, grouped, with value and
citation. Values are the genuine reference values; the derived sizes are captured
in vectors.json constants.
Application (LXMF.py)
| Constant | Value | Line |
|---|---|---|
APP_NAME | “lxmf” | 1 |
SF_COMPRESSION | 0x00 | 108 |
Message states (LXMessage.py:14-21) — informative
GENERATING 0x00, OUTBOUND 0x01, SENDING 0x02, SENT 0x04, DELIVERED
0x08, REJECTED 0xFD, CANCELLED 0xFE, FAILED 0xFF.
Representations and methods (LXMessage.py:24-32)
UNKNOWN 0x00, PACKET 0x01, RESOURCE 0x02. OPPORTUNISTIC 0x01, DIRECT
0x02, PROPAGATED 0x03, PAPER 0x05.
Unverified reasons (LXMessage.py:35-36)
SOURCE_UNKNOWN 0x01, SIGNATURE_INVALID 0x02.
Sizes (LXMessage.py:39-105)
| Constant | Value | Line |
|---|---|---|
DESTINATION_LENGTH | 16 | 39 |
SIGNATURE_LENGTH | 64 | 40 |
TICKET_LENGTH | 16 | 41 |
TIMESTAMP_SIZE | 8 | 60 |
STRUCT_OVERHEAD | 8 | 61 |
LXMF_OVERHEAD | 112 | 62 |
ENCRYPTED_PACKET_MDU | 391 | 67 |
ENCRYPTED_PACKET_MAX_CONTENT | 295 | 78 |
LINK_PACKET_MDU | 431 | 83 |
LINK_PACKET_MAX_CONTENT | 319 | 89 |
PLAIN_PACKET_MDU | 464 | 93 |
PLAIN_PACKET_MAX_CONTENT | 368 | 94 |
QR_MAX_STORAGE | 2953 | 104 |
PAPER_MDU | 2210 | 105 |
URI_SCHEMA | “lxm” | 102 |
Tickets (LXMessage.py:48-52)
| Constant | Value | Seconds | Line |
|---|---|---|---|
TICKET_EXPIRY | 21 days | 1 814 400 | 48 |
TICKET_GRACE | 5 days | 432 000 | 49 |
TICKET_RENEW | 14 days | 1 209 600 | 50 |
TICKET_INTERVAL | 1 day | 86 400 | 51 |
COST_TICKET | 0x100 (256) | — | 52 |
Fields (LXMF.py:8-41)
FIELD_EMBEDDED_LXMS 0x01 … FIELD_RENDERER 0x0F; FIELD_CUSTOM_TYPE 0xFB,
FIELD_CUSTOM_DATA 0xFC, FIELD_CUSTOM_META 0xFD; FIELD_NON_SPECIFIC 0xFE,
FIELD_DEBUG 0xFF. See Fields for the full table.
Renderers and audio modes (LXMF.py:55-92)
RENDERER_PLAIN 0x00 … RENDERER_BBCODE 0x03. AM_CODEC2_* 0x01–0x09,
AM_OPUS_* 0x10–0x19, AM_CUSTOM 0xFF.
Propagation metadata (LXMF.py:98-104)
PN_META_VERSION 0x00, PN_META_NAME 0x01, PN_META_SYNC_STRATUM 0x02,
PN_META_SYNC_THROTTLE 0x03, PN_META_AUTH_BAND 0x04, PN_META_UTIL_PRESSURE
0x05, PN_META_CUSTOM 0xFF.
Stamps (LXStamper.py:10-14)
| Constant | Value | Line |
|---|---|---|
WORKBLOCK_EXPAND_ROUNDS | 3000 | 10 |
WORKBLOCK_EXPAND_ROUNDS_PN | 1000 | 11 |
WORKBLOCK_EXPAND_ROUNDS_PEERING | 25 | 12 |
STAMP_SIZE | 32 | 13 |
PN_VALIDATION_POOL_MIN_SIZE | 256 | 14 |
Propagation peer (LXMPeer.py:14-50)
Paths OFFER_REQUEST_PATH “/offer” (14), MESSAGE_GET_PATH “/get” (15). States
IDLE 0x00 … RESOURCE_TRANSFERRING 0x05 (17-22). Errors ERROR_NO_IDENTITY
0xF0, ERROR_NO_ACCESS 0xF1, ERROR_INVALID_KEY 0xF3, ERROR_INVALID_DATA
0xF4, ERROR_INVALID_STAMP 0xF5, ERROR_THROTTLED 0xF6, ERROR_NOT_FOUND 0xFD,
ERROR_TIMEOUT 0xFE (24-31). STRATEGY_LAZY 0x01, STRATEGY_PERSISTENT 0x02
(33-34). MAX_UNREACHABLE 14 days, SYNC_BACKOFF_STEP 12 min,
PATH_REQUEST_GRACE 7.5 s (39-50).
Router (LXMRouter.py:30-83) — informative
See Router internals for the full set
(MAX_DELIVERY_ATTEMPTS, MESSAGE_EXPIRY, PROPAGATION_COST, etc.).
Coverage ledger
This is the traceability matrix from the frozen Symbol inventory to the specification. Every normative (N) symbol maps to a section and a proof. Every informative (I) and out-of-scope (X) symbol carries a reason. A normative symbol with no section and proof is a coverage gap.
Proof column: vector (a [VEC-...]), computed (derivation shown), quoted
(byte/enum value cited verbatim), n/a (informative/out-of-scope).
LXMessage.py
| Symbol(s) | file:line | Class | Section | Proof |
|---|---|---|---|---|
representation UNKNOWN/PACKET/RESOURCE | 24-26 | N | 05 | quoted |
method OPPORTUNISTIC/DIRECT/PROPAGATED/PAPER | 29-32 | N | 05 | quoted |
unverified SOURCE_UNKNOWN/SIGNATURE_INVALID | 35-36 | N | 03 | quoted |
size constants DESTINATION_LENGTH … PLAIN_PACKET_MAX_CONTENT | 39-94 | N | 02 | computed (vector constants) |
ticket constants TICKET_EXPIRY/GRACE/RENEW/INTERVAL, COST_TICKET | 48-52 | N | 08 | quoted (vector constants) |
URI_SCHEMA, QR_MAX_STORAGE, PAPER_MDU | 102-105 | N | 02, 05 | computed |
ENCRYPTION_DESCRIPTION_* | 97-99 | I | — | n/a (local labels) |
QR_ERROR_CORRECTION | 103 | I | — | n/a (QR rendering) |
| state constants | 14-21 | I | 06 | n/a (local lifecycle) |
set_title/content_*, *_as_string | 190-205 | N | 03 | vector VEC-MSG-1/3 |
set_fields/get_fields | 212-218 | N | 03, 04 | vector VEC-MSG-2 |
validate_stamp | 270 | N | 07 | vector VEC-STAMP-1 |
get_stamp | 293 | N | 07 | quoted |
get_propagation_stamp | 326 | N | 07, 10 | quoted |
pack | 352 | N | 03, 05 | vector VEC-MSG-1/2 |
__as_packet | 623 | N | 05, 06 | vector VEC-DLV-OPP/DIRECT |
__as_resource | 637 | N | 05, 06 | quoted |
packed_container, write_to_directory, unpack_from_file | 657-810 | I/N | 11 | n/a (local storage) |
as_uri | 687 | N | 05, 10 | vector VEC-PAPER-URI |
as_qr | 707 | I | — | n/a (QR rendering) |
unpack_from_bytes | 735 | N | 03 | vector VEC-MSG-3 |
send, __mark_*, __resource_concluded, timers | 460-620 | I | 06, 11 | n/a (orchestration) |
| msgpack sites 364/378/433/669/741/747 | — | N | 03, 10 | vector |
time.time() 354/433 | — | N | 03, 10 | vector (timestamp fields) |
LXMF.py
| Symbol(s) | file:line | Class | Section | Proof |
|---|---|---|---|---|
APP_NAME | 1 | N | 00 | quoted |
FIELD_* (all) | 8-41 | N | 04 | quoted (VEC-MSG-2 for one) |
AM_* audio modes | 55-79 | N | 04 | quoted |
RENDERER_* | 89-92 | N | 04 | quoted |
PN_META_* | 98-104 | N | 09 | quoted (VEC-ANN-PROPAGATION) |
SF_COMPRESSION | 108 | N | 12 | quoted |
display_name_from_app_data, stamp_cost_from_app_data | 117-152 | N | 09 | vector VEC-ANN-DELIVERY |
compression_support_from_app_data | 154 | N | 09 | quoted |
pn_name_from_app_data, pn_stamp_cost_from_app_data, pn_announce_data_is_valid | 169-217 | N | 09 | vector VEC-ANN-PROPAGATION |
LXStamper.py
| Symbol(s) | file:line | Class | Section | Proof |
|---|---|---|---|---|
WORKBLOCK_EXPAND_ROUNDS*, STAMP_SIZE | 10-13 | N | 07 | quoted |
stamp_workblock | 18 | N | 07 | vector VEC-STAMP-1 |
stamp_value | 31 | N | 07 | vector VEC-STAMP-1 |
stamp_valid | 42 | N | 07 | vector VEC-STAMP-1 |
validate_peering_key | 48 | N | 10 | quoted |
validate_pn_stamp | 53 | N | 10 | quoted |
generate_stamp | 92 | N | 07 | quoted |
validate_pn_stamps*, job_*, cancel_work, PN_VALIDATION_POOL_MIN_SIZE | 67-354 | I | 07 | n/a (PoW parallelism) |
Handlers.py
| Symbol(s) | file:line | Class | Section | Proof |
|---|---|---|---|---|
LXMFDeliveryAnnounceHandler.received_announce | 9-32 | N | 09 | vector VEC-ANN-DELIVERY |
LXMFPropagationAnnounceHandler.received_announce | 35-72 | N+I | 09, 11 | vector / n/a (auto-peer) |
LXMRouter.py
| Symbol(s) | file:line | Class | Section | Proof |
|---|---|---|---|---|
get_propagation_node_announce_metadata, get_propagation_node_app_data | 302-319 | N | 09 | vector VEC-ANN-PROPAGATION |
get_announce_app_data | 986 | N | 09 | vector VEC-ANN-DELIVERY |
generate_ticket, remember_ticket, get_outbound_ticket* | 1025-1086 | N | 08 | quoted |
message_get_request/list_response/get_response | 1427-1591 | N | 10 | quoted |
offer_request, propagation_packet, propagation_resource_concluded, lxmf_propagation, ingest_lxm_uri | 2110-2392 | N | 10 | quoted |
lxmf_delivery | 1732 | N | 06 | quoted |
PR_* states | 62-77 | N | 10 | quoted |
request paths STATS/SYNC/UNPEER | 81-83 | I | 11 | n/a |
| delivery/expiry/peer/job constants | 30-83, 853-860 | I | 11 | n/a (scheduling) |
persistence, queues, jobloop, rotation, time.time() | various | I | 11 | n/a (internal) |
LXMPeer.py
| Symbol(s) | file:line | Class | Section | Proof |
|---|---|---|---|---|
OFFER_REQUEST_PATH, MESSAGE_GET_PATH | 14-15 | N | 10 | quoted |
ERROR_* | 24-31 | N | 10 | quoted |
generate_peering_key | 242 | N | 10 | quoted |
sync (offer payload), offer_response, resource_concluded | 267-520 | N | 10 | quoted |
state constants, strategy, timing, from_bytes/to_bytes, peer counts | 17-50, 52-175, 544-640 | I | 11 | n/a (internal/persistence) |
| msgpack 462 (sync resource) | — | N | 10 | quoted |
_version.py / Utilities/lxmd.py
| Symbol(s) | file:line | Class | Section | Proof |
|---|---|---|---|---|
__version__ | _version.py:1 | N | 00 | quoted (pin) |
| daemon/CLI | lxmd.py | X | — | n/a (not protocol) |
Result
Every normative symbol in the inventory maps to a section and a proof above; no
normative row is left with an empty section or n/a proof. Informative and
out-of-scope rows are reasoned. Coverage is therefore complete against the frozen
inventory at the pinned commit. To re-audit after a reference bump, re-enumerate
the source and diff the inventory; any new symbol appears here unclassified.
Test vectors
These are the golden vectors that prove the binary claims in this specification.
They are the genuine output of the reference code, generated by
vectors/gen_vectors.py and stored in machine-readable
form in vectors/vectors.json.
Regenerate and verify (from the repository root):
PYTHONPATH=vendor/Reticulum:vendor/LXMF \
python3 docs/src/appendix/lxmf/vectors/gen_vectors.py
Pinned to LXMF 8499729 (0.9.6) and Reticulum d5e62d4 (RNS 1.3.5). Fixed
inputs: source identity private = 00010203…3f, destination identity private =
404142…7f, message timestamp = 1700000000.0.
Vector kinds
- frozen — deterministic; the hex is the proof; reproduces byte for byte.
- roundtrip — output depends on ephemeral encryption key material, so the ciphertext is not reproducible; proven by a decrypt round trip plus structural assertions.
VEC-MSG-1 (frozen) — minimal opportunistic message
title=b"Hi", content=b"Hello", fields={}. (LXMessage.py:359-384)
packed (118 B):
cf0b2a4a8d2a0b6978b71290da7cc80e destination_hash(16)
fae321c442e3c9bdcd7a3e79d850e03c source_hash(16)
fb321978105a4c709c3b86930ff15a9d
7b53b3485517ec19e2083b39f7661e6e
531c78fb71d932f0baf13794c42234ab
9320f1ab5b7688e93eaf5960810ece00 signature(64)
94cb41d954fc40000000c4024869c405
48656c6c6f80 packed_payload (msgpack)
message_id = 9aec506b63deab21d8fa4954d9f743cf20f5adeeb1abd1c7429bb3f832dc287b
signature_valid = true
VEC-MSG-2 (frozen) — fields dict with integer key
title=b"", content=b"body text", fields={0x0F: 0x02}. (LXMessage.py:359)
packed_payload: 94cb41d954fc40000000c400c409626f64792074657874810f02
94 array(4)
cb 41d954fc40000000 timestamp 1700000000.0
c4 00 title bin(0) = ""
c4 09 626f6479... content bin(9) = "body text"
81 0f 02 fields {0x0F: 0x02}
VEC-MSG-3 (frozen) — unpack + verify of VEC-MSG-1
After making the source identity recallable, unpack_from_bytes of VEC-MSG-1
yields signature_validated = true, matches_source = true, recovered
title="Hi", content="Hello". (LXMessage.py:735-807)
VEC-DLV-OPP (frozen) — opportunistic on-air payload
on_air = packed[16:] (destination hash omitted). (LXMessage.py:631)
VEC-DLV-DIRECT (frozen) — direct on-air payload
on_air = packed (full bytes over a link). (LXMessage.py:633)
VEC-PROP-ENVELOPE (roundtrip) — propagation envelope
Structure destination_hash(16) || destination.encrypt(packed[16:]); envelope
msgpack([timestamp, [lxmf_data]]); transient_id = full_hash(lxmf_data);
destination.decrypt(pn_encrypted) == packed[16:] holds. (LXMessage.py:423-433)
VEC-PAPER-URI (roundtrip) — paper URI
lxm://base64url(destination_hash(16) || destination.encrypt(packed[16:])) with
= padding stripped; observed prefix lxm://. (LXMessage.py:687-702)
VEC-STAMP-1 (frozen) — stamp workblock, validity, value
material = full_hash(b"lxmf-spec-stamp-material") = 1c91877ffb9797aa6f33064586b47a3c41f6dfa75e10aa17bc24bf0ac6833712,
expand_rounds=4, target_cost=8, workblock 1024 B. Deterministic search
stamp = full_hash(material || counter_be8) finds counter=377:
stamp = 9b79689af899049accea13624a3c59221603117e81086a86a3249ce278acc35e
target = 0100000000000000000000000000000000000000000000000000000000000000 (1 << 248)
valid = true
value = 8
(LXStamper.py:18-46)
VEC-ANN-DELIVERY (frozen) — delivery announce app_data
msgpack([b"Alice", 8]):
app_data: 92c405416c69636508
92 array(2)
c4 05 416c696365 display_name bin(5) = "Alice"
08 stamp_cost = 8
first_byte = 0x92 (new-format sniff)
decoded: display_name="Alice", stamp_cost=8
(LXMRouter.py:990-1002; LXMF.py:117-152)
VEC-ANN-PROPAGATION (frozen) — propagation node announce app_data
7-element list, metadata={PN_META_NAME: b"NodeA"}, stamp_costs=[16,3,18],
fixed timebase:
app_data: 97c2ce6553f100c3cd0100cd2800931003128101c4054e6f646541
valid = true; pn_name = "NodeA"; pn_stamp_cost = 16
(LXMRouter.py:307-319; LXMF.py:191-211)
LXMF reference symbol inventory (frozen)
This file is the frozen ground-truth enumeration of every symbol and wire surface in the vendored Python LXMF reference. The specification is written against it; the coverage ledger (Coverage ledger) maps every entry here to a specification section and a proof.
Do not edit by hand to reflect wishful coverage. To refresh, re-enumerate the source at the pinned commit and diff. A new symbol that appears unclassified is a coverage gap.
Pin
| Component | Version | Submodule commit |
|---|---|---|
LXMF (vendor/LXMF) | 0.9.6 | 8499729024a4cddfceb47ca07188bb5b1d11d179 |
Reticulum (vendor/Reticulum) | RNS 1.3.5 | d5e62d4e15c5fe2e170f7bd9e120551671f21a27 |
Reference files under vendor/LXMF/LXMF/: LXMessage.py, LXMF.py,
LXMRouter.py, LXMPeer.py, LXStamper.py, Handlers.py, _version.py,
Utilities/lxmd.py.
Classification key
- N normative: crosses the wire or is observable by a Python peer; must be specified exactly and proven.
- I informative: internal behaviour an implementer may diverge on without breaking interop; described, not byte-proven.
- X out of scope: daemon, CLI, build or test scaffolding.
LXMessage.py (827 lines) — class LXMessage (line 13)
Constants
| Symbol | Value | Line | Class |
|---|---|---|---|
state GENERATING/OUTBOUND/SENDING/SENT/DELIVERED/REJECTED/CANCELLED/FAILED | 0x00,0x01,0x02,0x04,0x08,0xFD,0xFE,0xFF | 14-21 | I |
representation UNKNOWN/PACKET/RESOURCE | 0x00,0x01,0x02 | 24-26 | N |
method OPPORTUNISTIC/DIRECT/PROPAGATED/PAPER | 0x01,0x02,0x03,0x05 | 29-32 | N |
unverified SOURCE_UNKNOWN/SIGNATURE_INVALID | 0x01,0x02 | 35-36 | N |
DESTINATION_LENGTH | 16 | 39 | N |
SIGNATURE_LENGTH | 64 | 40 | N |
TICKET_LENGTH | 16 | 41 | N |
TICKET_EXPIRY/GRACE/RENEW/INTERVAL | 21d/5d/14d/1d | 48-51 | N |
COST_TICKET | 0x100 | 52 | N |
TIMESTAMP_SIZE | 8 | 60 | N |
STRUCT_OVERHEAD | 8 | 61 | N |
LXMF_OVERHEAD | 112 | 62 | N |
ENCRYPTED_PACKET_MDU | derived | 67 | N |
ENCRYPTED_PACKET_MAX_CONTENT | 295 | 78 | N |
LINK_PACKET_MDU | RNS.Link.MDU | 83 | N |
LINK_PACKET_MAX_CONTENT | 319 | 89 | N |
PLAIN_PACKET_MDU | RNS.Packet.PLAIN_MDU | 93 | N |
PLAIN_PACKET_MAX_CONTENT | 368 | 94 | N |
ENCRYPTION_DESCRIPTION_AES/EC/UNENCRYPTED | strings | 97-99 | I |
URI_SCHEMA | “lxm” | 102 | N |
QR_ERROR_CORRECTION | “ERROR_CORRECT_L” | 103 | I |
QR_MAX_STORAGE | 2953 | 104 | N |
PAPER_MDU | 2210 | 105 | N |
Methods (wire-relevant marked N)
| Method | Line | Class |
|---|---|---|
__init__ | 113 | N (field defaults) |
set_title_from_string/bytes, title_as_string | 190-196 | N |
set_content_from_string/bytes, content_as_string | 199-205 | N |
set_fields, get_fields | 212-218 | N |
validate_stamp | 270 | N |
get_stamp | 293 | N |
get_propagation_stamp | 326 | N |
pack | 352 | N |
send | 460 | I |
determine_compression_support | 507 | N |
determine_transport_encryption | 517 | I |
__mark_delivered/propagated/paper_generated | 558-582 | I |
__resource_concluded, __propagation_resource_concluded | 594-605 | I |
__link_packet_timed_out, __update_transfer_progress | 613-620 | I |
__as_packet | 623 | N |
__as_resource | 637 | N |
packed_container | 657 | N |
write_to_directory | 672 | I |
as_uri | 687 | N |
as_qr | 707 | I |
unpack_from_bytes (static) | 735 | N |
unpack_from_file (static) | 810 | I |
msgpack sites
364 (pack payload, N), 378 (pack payload, N), 433 (propagation envelope, N), 669 (packed_container, N), 741 (unpack payload, N), 747 (re-pack for hash, N), 812 (unpack from file, I).
RNS primitives
Identity.full_hash 365/431, Identity.truncated_hash 274, source.sign 375,
identity.validate 794, Destination.encrypt 427/446, Destination.SINGLE/PLAIN/LINK/GROUP
395-548, Packet 476, Link.ACTIVE 647, Resource 651/653, Identity.recall 759/765,
size constants Identity.TRUNCATED_HASHLENGTH/SIGLENGTH 39/40, Packet.ENCRYPTED_MDU/PLAIN_MDU
67/93, Link.MDU 83.
Filesystem / time
open/write 677-679 (write_to_directory, I). time.time() 354, 433 (N: payload
and envelope timestamps).
LXMF.py (217 lines) — module
Constants
| Symbol | Value | Line | Class |
|---|---|---|---|
APP_NAME | “lxmf” | 1 | N |
FIELD_EMBEDDED_LXMS..FIELD_RENDERER | 0x01..0x0F | 8-22 | N |
FIELD_CUSTOM_TYPE/DATA/META | 0xFB-0xFD | 34-36 | N |
FIELD_NON_SPECIFIC/DEBUG | 0xFE-0xFF | 40-41 | N |
AM_CODEC2_* | 0x01..0x09 | 55-63 | N |
AM_OPUS_* | 0x10..0x19 | 66-75 | N |
AM_CUSTOM | 0xFF | 79 | N |
RENDERER_PLAIN/MICRON/MARKDOWN/BBCODE | 0x00-0x03 | 89-92 | N |
PN_META_VERSION..PN_META_CUSTOM | 0x00..0xFF | 98-104 | N |
SF_COMPRESSION | 0x00 | 108 | N |
Functions
| Function | Line | Class |
|---|---|---|
display_name_from_app_data | 117 | N |
stamp_cost_from_app_data | 141 | N |
compression_support_from_app_data | 154 | N |
pn_name_from_app_data | 169 | N |
pn_stamp_cost_from_app_data | 182 | N |
pn_announce_data_is_valid | 191 | N |
msgpack sites
123, 146, 159, 173, 186, 194 (all unpack of announce app_data, N).
LXStamper.py (396 lines) — module
Constants
| Symbol | Value | Line | Class |
|---|---|---|---|
WORKBLOCK_EXPAND_ROUNDS | 3000 | 10 | N |
WORKBLOCK_EXPAND_ROUNDS_PN | 1000 | 11 | N |
WORKBLOCK_EXPAND_ROUNDS_PEERING | 25 | 12 | N |
STAMP_SIZE | 32 | 13 | N |
PN_VALIDATION_POOL_MIN_SIZE | 256 | 14 | I |
Functions
| Function | Line | Class |
|---|---|---|
stamp_workblock | 18 | N |
stamp_value | 31 | N |
stamp_valid | 42 | N |
validate_peering_key | 48 | N |
validate_pn_stamp | 53 | N |
validate_pn_stamps_job_simple/multip, validate_pn_stamps | 67-87 | I (parallelism) |
generate_stamp | 92 | N (algorithm) |
cancel_work | 113 | I |
job_simple/linux/android | 145-260 | I (platform PoW workers) |
msgpack sites
24 (salt packb(n), N). RNS: Cryptography.hkdf 22, Identity.full_hash 24/34/44.
Handlers.py (92 lines)
| Class / method | Line | Class |
|---|---|---|
LXMFDeliveryAnnounceHandler.received_announce | 9/15 | N (announce parsing) |
LXMFPropagationAnnounceHandler.received_announce | 35/41 | N + I (auto-peer is I) |
msgpack: 46 (unpack announce, N). RNS: Transport.hops_to 71/72 (I).
LXMRouter.py (2733 lines) — class LXMRouter (line 29)
Mostly I (router internals: jobloop, queues, persistence, peer rotation, retry cadences). The N surfaces are the announce/app-data builders and the propagation request handlers and packers.
Normative surfaces
| Symbol | Line | Class |
|---|---|---|
get_propagation_node_announce_metadata | 302 | N |
get_propagation_node_app_data | 307 | N |
get_announce_app_data | 986 | N |
generate_ticket | 1025 | N |
message_get_request | 1427 | N (/get request shape) |
message_list_response | 1507 | N |
message_get_response | 1552 | N |
offer_request | 2142 | N (/offer handler) |
propagation_packet | 2110 | N |
propagation_resource_concluded | 2194 | N |
lxmf_propagation | 2310 | N (transient ingest) |
ingest_lxm_uri | 2370 | N (paper ingest) |
lxmf_delivery | 1732 | N (inbound delivery dispatch) |
Informative constants (selected; full set in Router internals)
MAX_DELIVERY_ATTEMPTS=5(30), PROCESSING_INTERVAL=4(31), DELIVERY_RETRY_WAIT=10(32),
PATH_REQUEST_WAIT=7(33), MESSAGE_EXPIRY=30d(38), STAMP_COST_EXPIRY=45d(39),
MAX_PEERS=20(43), AUTOPEER_MAXDEPTH=4(45), PEERING_COST=18(50), PROPAGATION_COST=16(54),
PROPAGATION_LIMIT=256(55), SYNC_LIMIT=256*40(56), DELIVERY_LIMIT=1000(57),
PN_STAMP_THROTTLE=180(60), PR_* states 62-77 (N: appear in /get FSM signalling),
request paths STATS_GET/SYNC_REQUEST/UNPEER_REQUEST 81-83 (I), JOB_* intervals 853-860 (I).
Persistence / time
Extensive filesystem use (message store, peers, tickets, costs, stats) — all I.
80+ time.time() calls — I except where a value is signed or hashed.
LXMPeer.py (642 lines) — class LXMPeer (line 13)
| Symbol | Line | Class |
|---|---|---|
OFFER_REQUEST_PATH="/offer", MESSAGE_GET_PATH="/get" | 14-15 | N |
state IDLE..RESOURCE_TRANSFERRING | 17-22 | I |
ERROR_NO_IDENTITY..ERROR_TIMEOUT | 24-31 | N (/offer response codes) |
STRATEGY_LAZY/PERSISTENT, DEFAULT_SYNC_STRATEGY | 33-35 | I |
MAX_UNREACHABLE=14d, SYNC_BACKOFF_STEP=12m, PATH_REQUEST_GRACE=7.5 | 39-50 | I |
from_bytes/to_bytes (peer persistence) | 52/138 | I |
generate_peering_key | 242 | N |
sync | 267 | I + N (offer payload shape) |
offer_response | 396 | N |
resource_concluded | 488 | N (sync resource payload) |
msgpack: 54/172 (peer persistence, I), 462 (sync resource packb([time, lxm_list]), N).
_version.py (2 lines)
__version__ = "0.9.6" (1) — N (reference pin).
Utilities/lxmd.py (1127 lines)
Daemon and CLI. X out of scope (not protocol). Listed for completeness only.
Deferred RNS primitives (cited into vendor/Reticulum, not re-specified)
RNS.Identity.full_hash (SHA-256), truncated_hash, Ed25519 sign/validate,
RNS.Destination.encrypt/decrypt (ECDH + AES token + ratchets),
RNS.Cryptography.hkdf, and the size constants RNS.Identity.TRUNCATED_HASHLENGTH
(128), SIGLENGTH (512), HASHLENGTH (256), RNS.Packet.ENCRYPTED_MDU/PLAIN_MDU,
RNS.Link.MDU. Their exact behaviour-as-used is pinned by the test vectors.