Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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:

The crate split

The protocol logic lives in one no_std crate; everything platform- specific wraps around it:

CrateRole
reticulum-coreAll protocol logic, #![no_std] + alloc, zero async (reticulum-core/src/lib.rs:59).
reticulum-stdHost driver: tokio event loop, interfaces, FileStorage, RPC, config.
reticulum-nrfEmbedded driver: Embassy event loop on nRF52 (cross-compiled, outside the host workspace).
reticulum-ffiC ABI over the core for other-language bindings.
reticulum-cliThe 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)

  1. dispatch_actions(&mut ifaces, &output.actions) — routes Actions to interfaces (protocol logic in core)
  2. React to errors — BufferFull: log. Disconnected: call handle_interface_down()
  3. Forward output.events to the application
  4. Schedule handle_timeout() from output.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:

CollectionKey methods
Packet deduphas_packet_hash, add_packet_hash
Path tableget_path, set_path, remove_path, expire_paths
Reverse tableget_reverse, set_reverse, remove_reverse
Link tableget_link_entry, set_link_entry, remove_link_entry
Announce tableget_announce, set_announce, remove_announce
Announce cacheget_announce_cache, set_announce_cache
Receiptsget_receipt, set_receipt, remove_receipt
Ratchetsload_ratchet, store_ratchet, list_ratchet_keys
Cleanupexpire_* 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

FileFormatStrategyContents
known_destinationsmsgpack mapBatch flush (hourly + shutdown)Identity → destination
packet_hashlistmsgpack arrayBatch flush32-byte dedup hashes
ratchets/{hash}msgpack mapWrite-throughReceiver ratchet keys
ratchetkeys/{hash}signed msgpackWrite-throughSender 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”).

ComponentWhatLevel
transport process_incomingPacket dispatch, drop reasonstrace!
transport handle_announcePath updates, rebroadcast decisionsdebug!
transport forward_packetForwarding decisionsdebug!
node/link_managementLink lifecycle, RTT retrydebug!
driverStartup, interface registrationinfo!
interfacesConnection events, I/O errorsinfo!/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, the compute_jitter_max_ms doc 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 BufferFull rather than flooding the serial queue (reticulum-std/src/interfaces/airtime.rs:1). This explicitly “never leaks into reticulum-core, so the no_std core 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, and rncp drive a running lnsd without modification, and the Leviculum tools lns and lncp drive a running rnsd just the same. The RPC control channel that backs rnstatus/rnpath/ rnprobe is implemented in reticulum-std/src/rpc/ (it speaks Python’s multiprocessing.connection framing with pickle payloads, see rpc/connection.rs and rpc/pickle.rs).
  • The config-file format. lnsd parses the same INI-style config that rnsd uses (reticulum-std/src/config.rs, reticulum-std/src/ini_config.rs). Even keys Leviculum does not act on are parsed for compatibility — for example shared_instance_type and shared_instance_socket are read and honoured per RNS 1.3.x semantics so an existing rnsd config 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:

  1. Wire-format compatibility is preserved.
  2. Semantic compatibility is preserved (behaviours Python peers expect from a neighbour are still delivered).
  3. 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.

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:

  1. A destination enables ratchets and generates an initial X25519 keypair.
  2. It includes the current ratchet public key in its announces.
  3. Senders encrypt to the ratchet public key, not the long-term identity key.
  4. The destination rotates its ratchet keypair periodically (default ~30 minutes).
  5. 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) — supplies now_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>):

BackendWhereBehaviour
NoStoragetiny / statelessno-op
MemoryStoragehost / testsBTreeMap, RAM only (inner store of FileStorage)
EmbeddedStorageembedded (nRF52)heapless::FnvIndexMap, fixed capacity, no allocator for maps
FileStoragehost (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-std builds a tokio driver around the core; reticulum-nrf builds 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 via NodeCoreBuilder).
  • 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 under reticulum-std/tests/mvr/.
  • No host concerns in the core. Backpressure, airtime budgeting, and serial queueing live host-side in reticulum-std and never leak into the no_std core (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):

CommandWhat it does
cargo test-coreRun all reticulum-core unit tests
cargo test-stdRun all reticulum-std unit tests
cargo test-interopRun interop tests against Python Reticulum
cargo test-integRun Docker-based integration tests
cargo lintRun clippy on all crates
cargo fmt --all -- --checkCheck 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 neededTest countExamples
2 RNodes40lora_link_rust, lora_lncp_push, lora_ratchet_basic
3 RNodes3lora_3node_transfer, lora_3node_contention
4 RNodes7lora_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 dialout group: 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):

  1. /etc/reticulum — if /etc/reticulum/config exists
  2. $HOME/.config/reticulum — if that directory’s config exists
  3. $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 .toml extension 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.

KeyTypeDefaultMeaning
enable_transportbooltrueRoute 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_proofbooltrueUse implicit proof for link identification. (config.rs:30-31, 141)
share_instanceboolfalseListen 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_instanceshared_instance, ini_config.rs:158-159)
instance_namestringdefaultNames 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_typeunix/tcpunsetParsed 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_socketpathunsetExplicit 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_probesboolfalseAnswer rnprobe requests by signing a proof for each probe packet. (config.rs:54-60, 146; ini_config.rs:177-179)
remote_management_enabledboolfalseEnable remote management. (config.rs:61-63, 147; ini_config.rs:180-182)
storage_pathpathunsetStorage path, relative to the config dir or absolute. (config.rs:64-66, 148)
flush_intervalu64 (sec)3600Seconds between periodic storage flushes. Crash protection only — normal shutdown always flushes. (config.rs:67-73, 149; ini_config.rs:183-187)
control_channel_capacityusize256Capacity 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_capacityusize128Capacity of the droppable data-plane event channel; full means normal backpressure (silent drop). (config.rs:83-90, 151)
keepalive_intervalu64 (sec)unsetOverride 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

KeyTypeDefaultMeaning
typestring(required)Interface type, one of the six above. (ini_config.rs:204)
enabledbooltrueBring this interface up. (ini_config.rs:205; config.rs:164-165)
outgoingbooltrueAllow sending outgoing packets. (ini_config.rs:206; config.rs:167-168)
bitrateu64 (bps)62500Advertised link bitrate, used for airtime accounting. (ini_config.rs:218-222; config.rs:170-171, 253)
buffer_sizeusizeper typeChannel buffer size. (ini_config.rs:223; config.rs:200-201)

TCP server (TCPServerInterface)

KeyTypeDefaultMeaning
listen_ipstringunsetAddress to bind. (ini_config.rs:207)
listen_portu16unsetPort 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)

KeyTypeDefaultMeaning
target_hoststringunsetRemote host to connect to. (ini_config.rs:209)
target_portu16unsetRemote port. (ini_config.rs:210)
reconnect_intervalu64 (sec)5Delay between reconnect attempts. (ini_config.rs:224; config.rs:202-203)
max_reconnect_triesu64unlimitedGive 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)

KeyTypeDefaultMeaning
listen_ipstringunsetLocal bind address. (ini_config.rs:207)
listen_portu16unsetLocal bind port. (ini_config.rs:208)
forward_ipstringunsetBroadcast/forward address. (ini_config.rs:211)
forward_portu16unsetBroadcast/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.

KeyTypeDefaultMeaning
group_idstringunsetMulticast group identifier; isolate co-located meshes by setting different IDs. (ini_config.rs:232; config.rs:208-209)
discovery_scopestringunsetMulticast scope: link, admin, site, organisation, global. (ini_config.rs:233; config.rs:210-211)
discovery_portu1629716Discovery (announce) port. (ini_config.rs:234; config.rs:212-213)
data_portu1642671Data port. (ini_config.rs:235; config.rs:214-215)
devicesstring (CSV)unsetWhitelist of NIC names to use. (ini_config.rs:236; config.rs:216-217)
ignored_devicesstring (CSV)unsetBlacklist of NIC names to skip. (ini_config.rs:237; config.rs:218-219)
multicast_loopbackboolunsetEnable 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:

KeyTypeDefaultMeaning
portstringunsetSerial device path, e.g. /dev/ttyACM0. (ini_config.rs:213; config.rs:188-189)
speed / baudrateu32unsetSerial baud rate (either spelling). (ini_config.rs:214; config.rs:190-191)
databitsu8unsetData bits. (ini_config.rs:215; config.rs:192-193)
paritystringunsetnone, even, or odd. (ini_config.rs:216; config.rs:194-195)
stopbitsu8unsetStop 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):

KeyTypeDefaultMeaning
frequencyu64 (Hz)unsetLoRa centre frequency. (ini_config.rs:226; config.rs:232-233)
bandwidthu32 (Hz)unsetLoRa bandwidth. (ini_config.rs:227; config.rs:234-235)
spreadingfactor / spreading_factoru8unsetLoRa spreading factor (either spelling). (ini_config.rs:228; config.rs:236-237)
codingrate / coding_rateu8unsetLoRa coding rate (either spelling). (ini_config.rs:229; config.rs:238-239)
txpower / tx_poweri8 (dBm)unsetTransmit power (either spelling). (ini_config.rs:230; config.rs:240-241)
flow_controlboolunsetWait for the RNode’s CMD_READY before the next TX. (ini_config.rs:239; config.rs:242-243)
airtime_limit_shortf64 (%)unsetShort-term airtime cap, percent (0.0–100.0). (ini_config.rs:240; config.rs:244-245)
airtime_limit_longf64 (%)unsetLong-term airtime cap, percent (0.0–100.0). (ini_config.rs:241; config.rs:246-247)
csma_enabledboolunsetEnable 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):

KeyTypeDefaultMeaning
networkname / network_namestringunsetNetwork name for IFAC (either spelling). (ini_config.rs:243; config.rs:224-225)
passphrasestringunsetIFAC passphrase. (ini_config.rs:244; config.rs:226-227)
ifac_sizeusize (bits)unsetIFAC 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 .deb path.)
  • The nightly .deb for 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, and lncp under /usr/bin/.
  • Creates a system user leviculum and 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.service systemd 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=up on every interface in the interface_stats section. An interface that came up but lost its medium reports status=down.
  • Non-zero rxb / txb on the interfaces you expect traffic on (AutoInterface once any other Reticulum node is on the same LAN, TCPInterface as soon as it connects).
  • peers=… on the AutoInterface line: how many other Reticulum nodes are visible on the LAN.
  • known paths: N with N > 0 once announces have crossed the mesh. Brand-new daemons that haven’t heard any announces yet show known paths: 0 for the first few seconds — that’s normal.
  • transport id is your node’s identity (the public half). It is safe to share; the private half lives in /etc/reticulum/storage/transport_identity and is never included in lns diag output.

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:

  1. 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.
  2. Learning paths. When other nodes announce, your daemon stores a path to each announced destination (hash, next hop, hop count, expiry). lns diag’s known paths: N is 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-rpc shows the parse status without needing the daemon up:
    config file: present but FAILED to parse: <details>
    
  • Abstract socket already in use. Another lnsd or rnsd is running under the same instance_name. Stop it (sudo systemctl stop lnsd then pkill -f rnsd if applicable), or set a unique instance_name in your config.
  • Permission on the storage directory. The leviculum user has to be able to write /etc/reticulum/storage/. The .deb sets the permissions correctly on install; a manual chown to root:root breaks 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:

  • AutoInterface shows peers: 0 and rxb: 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.
  • TCPClientInterface shows status=up but rxb: 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.
  • TCPClientInterface not listed at all — the daemon hasn’t connected yet (look for Establishing TCP connection lines in journalctl -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 leviculum group:
    id | tr , '\n' | grep leviculum
    
    If not, sudo 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/reticulum if it exists, then ~/.config/reticulum, then ~/.reticulum. lns diag --config /etc/reticulum is 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

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):

CommandAction
/peersList discovered destinations
/link <hash>Initiate a link to a destination (32-char hex)
/target <hash>Set a single-packet destination (32-char hex)
/untargetClear the single-packet target
/send <msg>Send data on the active link or to the target
/closeClose the active link
/announceRe-announce this destination
/quietHide announce/path messages
/verboseShow announce/path messages
/statusShow node status (identity, destination, paths, peers)
/helpShow this help
/quitExit
<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 / buildlns version, 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 live interface_stats, path_table, and link_count queries.
  • System — OS, kernel, distro, and the daemon’s pid / RSS / open fds.
  • Recent events — tail of the structured event log if --event-log is 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 diag or the Python rnstatus.
  • For paths and interfaces, use the interface_stats and path_table sections of lns diag, or rnpath / 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

OptionMeaning
--config <DIR>Use an alternative Reticulum config directory instead of the default lookup.
-v/--verbose, -q/--quietRaise / lower log verbosity (stackable).
-l/--listenListen 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/--overwriteAllow overwriting existing received files.
-n/--no-authAccept requests from anyone (overrides -a).
-b <INTERVAL>Announce interval: -1 never, 0 once at startup, N every N seconds. Default: 0.
-p/--print-identityPrint the destination hash and identity hash, then exit.
-i <IDENTITY>Use this identity file instead of the default.
-S/--silentFully silent: no progress and no log output (equivalent to -qq).
-C/--no-compressDisable automatic compression.
-f/--fetchFetch a file from a remote listener (instead of sending).
-F/--allow-fetchAllow authenticated clients to fetch files (listen mode).
-j/--jail <PATH>Restrict fetch requests to this directory (use with -F).
-P/--phy-ratesDisplay 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-subscriber documentation 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_LOG set).
–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-core library 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:

InterfaceIDMediumHW MTU
serial_usb0USB CDC-ACM, HDLC framing to host564
lora_sx12621SX1262 LoRa radio255
ble2BLE peripheral, Columba v2.2564

(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

BoardSoC + radioBLEBaseboard peripherals
Heltec Mesh Node T114nRF52840 + SX1262yes (Columba v2.2)none
RAK4631 / WisMesh Pocket V2nRF52840 + SX1262yes (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 Cargo softdevice feature — 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)

FeatureEffectCite
bsp-t114T114 BSP (+ SoftDevice BLE)Cargo.toml:116
bsp-rak4631RAK4631 BSP (+ SoftDevice BLE)Cargo.toml:115
displaySSD1306 OLED on baseboardCargo.toml:118
gnssNMEA0183 GNSS on baseboardCargo.toml:119
batterybattery telemetry on baseboardCargo.toml:120
rak-baseboardaggregate of display + gnss + batteryCargo.toml:121

The mapping from board to binary + features used by the flash recipes:

BoardBinaryFeatures
Heltec Mesh Node T114t114bsp-t114
RAK4631 (bare module)rak4631bsp-rak4631
WisMesh Pocket V2 (full baseboard)rak4631bsp-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.

ParameterValue
Frequency869.525 MHz (EU ISM band)
Spreading factorSF7
Bandwidth125 kHz
Coding rateCR4/5
TX power17 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 Justfile and reticulum-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; all just 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 with just flash (from the repo root), which wraps cargo 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:

PortUSB interface numsCarries
Debug00 (comm) + 01 (data)human-readable log lines
Transport02 (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:

BoardUSB VID:PIDDebug symlinkTransport symlink
T1141209:0001/dev/leviculum-debug/dev/leviculum-transport
RAK4631 / Pocket V21209: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 and SYMLINK+="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.)

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.md and the Justfile; 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/ttyACM0
    

    which 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 the meshtastic CLI (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.rs and 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:

  1. 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).

  2. Re-flash the known-good LNode firmware once the UF2 drive appears: just flash (T114) or just flash-rak4631 / just flash-rak4631-pocket (RAK4631). See Flashing.

  3. 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 115200
    

    Look 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…UseWhy
A Linux/macOS app or daemon talking to a meshreticulum-stdAsync handle API, real interfaces, file storage, tokio loop already wired
A drop-in tool reusing a running lnsd/rnsdreticulum-stdconnect_to_shared_instance over the shared-instance IPC
A relay / transport nodereticulum-stdenable_transport(true), see relay_daemon.rs
A C program (any non-Rust language with C FFI)reticulum-ffiStable C ABI, opaque handles, pollable fd — see the C API chapters
Firmware on an nRF52 LoRa boardreticulum-nrfReference firmware; fork or adapt it
Firmware on a different MCU / a custom async runtimereticulum-coreImplement Clock/Storage/Interface, drive the sans-IO loop yourself
A simulator or byte-level test harness with no I/Oreticulum-coreFeed 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.

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.rs and echo_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 and transport_stats().
  • link_test.rs / link_integration_test.rs — these drop down to reticulum-core’s Link directly against a Python rnsd, 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.

SignaturePurpose
fn new() -> Result<Self>reticulum.rs:22Build from the default config path, or defaults if absent
fn with_config(config: Config) -> Result<Self>reticulum.rs:37Build from an explicit Config
fn with_config_daemon(config: Config) -> Result<Self>reticulum.rs:55Like with_config but with no application event channel (daemon mode); take_event_receiver() then returns None
async fn start(&mut self) -> Result<()>reticulum.rs:67Spawn the event loop
async fn stop(&mut self) -> Result<()>reticulum.rs:73Stop and persist
fn is_running(&self) -> boolreticulum.rs:80Whether the loop is running
fn config(&self) -> &Configreticulum.rs:85Borrow the active config
fn take_event_receiver(&mut self) -> Option<EventReceiver>reticulum.rs:110Take 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.

SignaturePurpose
fn new() -> Selfbuilder.rs:74Builder with defaults
fn identity(self, identity: Identity) -> Selfbuilder.rs:113Pin an explicit identity (else one is generated/persisted)
fn add_tcp_client(self, addr: SocketAddr) -> Selfbuilder.rs:155Connect outward to a Reticulum node
fn add_tcp_server(self, addr: SocketAddr) -> Selfbuilder.rs:168Listen for inbound connections
fn add_udp_interface(self, listen: SocketAddr, forward: SocketAddr) -> Selfbuilder.rs:182One datagram per packet
fn add_rnode_interface(self, port: String, frequency: u64, bandwidth: u32, spreading_factor: u8, coding_rate: u8, tx_power: i8) -> Selfbuilder.rs:198LoRa interface; required radio settings
fn add_serial_interface(self, port: String, speed: u32, databits: u8, parity: String, stopbits: u8) -> Selfbuilder.rs:222KISS over raw serial
fn add_auto_interface(self) -> Selfbuilder.rs:246IPv6 multicast LAN discovery
fn enable_transport(self, enabled: bool) -> Selfbuilder.rs:281Act as a relay/forwarder
fn config(self, config: Config) -> Selfbuilder.rs:129Use a pre-loaded Config
fn config_file(self, path: PathBuf) -> Selfbuilder.rs:139Load an INI config file
fn storage_path(self, path: PathBuf) -> Selfbuilder.rs:147Identity / known-destinations / ratchet store dir
fn connect_to_shared_instance(self, name: impl Into<String>) -> Selfbuilder.rs:322Attach to a running lnsd/rnsd instead of bringing up own interfaces
fn without_events(self) -> Selfbuilder.rs:105Daemon mode: no application event channel
async fn build(self) -> Result<ReticulumNode, Error>builder.rs:518Build the node (not yet running)
fn build_sync(self) -> Result<ReticulumNode, Error>builder.rs:389Same 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:

SignaturePurpose
async fn start(&mut self) -> Result<(), Error>driver/mod.rs:575Spawn the event loop, bring interfaces up
async fn stop(&mut self) -> Result<(), Error>driver/mod.rs:1123Stop and flush
fn is_running(&self) -> booldriver/mod.rs:1176Loop state
fn register_destination(&self, destination: Destination)driver/mod.rs:1184Make a local destination reachable (consumes it)
async fn announce_destination(&self, dest_hash: &DestinationHash, app_data: Option<&[u8]>) -> …driver/mod.rs:1590Announce a registered destination
async fn connect(&self, dest_hash: &DestinationHash, dest_signing_key: &[u8; 32]) -> Result<LinkHandle, Error>driver/mod.rs:1202Open a link; returns a pending handle
fn link_handle(&self, link_id: &LinkId) -> LinkHandledriver/mod.rs:1235Writable handle for an already-established inbound link
fn packet_sender(&self, dest_hash: &DestinationHash) -> PacketSenderdriver/mod.rs:1843Single-packet send handle
async fn send_single_packet(&self, …) -> …driver/mod.rs:1797Send one unreliable datagram
fn take_event_receiver(&mut self) -> Option<EventReceiver>driver/mod.rs:1251Take the event stream, once
fn identity_hash(&self) -> [u8; 16]driver/mod.rs:1357The node’s own identity hash
fn has_path(&self, dest_hash: &DestinationHash) -> booldriver/mod.rs:1402Whether a path is known
fn hops_to(&self, dest_hash: &DestinationHash) -> Option<u8>driver/mod.rs:1443Hop count to a destination
async fn request_path(&self, dest_hash: &DestinationHash) -> Result<(), Error>driver/mod.rs:1427Send a PATH_REQUEST; result arrives as PathFound
fn get_identity(&self, dest_hash: &DestinationHash) -> Option<Identity>driver/mod.rs:1411Identity learned from an announce (its signing key feeds connect)
fn transport_stats(&self) -> TransportStatsdriver/mod.rs:1537rnstatus-style counters
fn is_transport_enabled(&self) -> booldriver/mod.rs:1857Relay 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.

SignaturePurpose
fn link_id(&self) -> &LinkIdstream.rs:72The link’s id
fn is_closed(&self) -> boolstream.rs:77Handle state
async fn try_send(&self, data: &[u8]) -> Result<(), Error>stream.rs:86Non-blocking send; surfaces Busy / PacingDelay
async fn send(&self, data: &[u8]) -> Result<(), Error>stream.rs:108Send, retrying pacing/busy internally
async fn close(&mut self) -> Result<(), Error>stream.rs:145Graceful 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.

SignaturePurpose
fn dest_hash(&self) -> &DestinationHashsender.rs:63The target destination
async fn send(&self, data: &[u8]) -> Result<[u8; TRUNCATED_HASHBYTES], Error>sender.rs:74Send 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.

SignaturePurpose
async fn recv(&mut self) -> Option<NodeEvent>driver/mod.rs:273Next event, control plane prioritized; None once shut down. Cancel-safe
fn try_recv(&mut self) -> Result<NodeEvent, TryRecvError>driver/mod.rs:298Non-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):

VariantFieldsSource
AnnounceReceivedannounce: ReceivedAnnounce, interface_index: usizeevent.rs:24
PathFounddestination_hash: DestinationHash, hops: u8, interface_index: usizeevent.rs:32
PacketReceiveddestination: DestinationHash, data: Vec<u8>, interface_index: usizeevent.rs:59
PacketDeliveryConfirmedpacket_hash: [u8; TRUNCATED_HASHBYTES]event.rs:69
LinkEstablishedlink_id: LinkId, is_initiator: boolevent.rs:84
MessageReceivedlink_id: LinkId, msgtype: u16, sequence: u16, data: Vec<u8>event.rs:95
LinkDataReceivedlink_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).

SignaturePurpose
fn load<P: AsRef<Path>>(path: P) -> Result<Self>config.rs:315Load an INI config (the rnsd/lnsd format)
fn default_config_dir() -> PathBufconfig.rs:360Default config directory
fn default_config_path() -> PathBufconfig.rs:369Default 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.

SignaturePurpose
fn new(identity: Identity, config: TransportConfig, proof_strategy: ProofStrategy, max_incoming_resource_size: usize, rng: R, clock: C, storage: S) -> Selfnode/mod.rs:213Construct directly
fn register_destination(&mut self, dest: Destination)node/mod.rs:256Register a local destination
fn announce_destination(&mut self, dest_hash: &DestinationHash, app_data: Option<&[u8]>) -> Result<TickOutput, AnnounceError>node/mod.rs:410Build and queue an announce
fn send_single_packet(&mut self, dest_hash: &DestinationHash, data: &[u8]) -> Result<([u8; TRUNCATED_HASHBYTES], TickOutput), SendError>node/mod.rs:473Build an unreliable data packet
fn connect(&mut self, dest_hash: DestinationHash, dest_signing_key: &[u8; 32]) -> (LinkId, bool, TickOutput)node/link_management.rs:185Build a link request
fn send_on_link(&mut self, link_id: &LinkId, data: &[u8]) -> Result<TickOutput, SendError>node/link_management.rs:504Send on an established link
fn close_link(&mut self, link_id: &LinkId) -> TickOutputnode/link_management.rs:419Close a link
fn handle_packet(&mut self, iface: InterfaceId, data: &[u8]) -> TickOutputnode/mod.rs:1006Feed received bytes from an interface
fn handle_timeout(&mut self) -> TickOutputnode/mod.rs:1098Run periodic maintenance (call at the next deadline)
fn next_deadline(&self) -> Option<u64>node/mod.rs:1129Earliest 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.

FieldTypeSource
actionsVec<Action> — I/O for the driver to executetransport.rs:140
eventsVec<NodeEvent> — application-visible eventstransport.rs:142
next_deadline_msOption<u64> — when to next call handle_timeouttransport.rs:145

Action is the I/O the driver performs, defined at reticulum-core/src/transport.rs:113:

VariantFieldsSource
SendPacketiface: InterfaceId, data: Vec<u8>transport.rs:115
Broadcastdata: 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.

SignaturePurpose
fn generate<R: CryptoRngCore>(rng: &mut R) -> Selfidentity.rs:71New random identity
fn from_public_key_bytes(bytes: &[u8]) -> Result<Self, IdentityError>identity.rs:113Public-only identity
fn from_private_key_bytes(bytes: &[u8]) -> Result<Self, IdentityError>identity.rs:127From the raw 64-byte private key (Python-compatible)
fn hash(&self) -> &[u8; IDENTITY_HASHBYTES]identity.rs:155The 16-byte identity hash
fn public_key_bytes(&self) -> [u8; IDENTITY_KEY_SIZE]identity.rs:16064 bytes: X25519 [0..32], Ed25519 [32..64]
fn has_private_keys(&self) -> boolidentity.rs:185Whether it can sign/decrypt
fn sign(&self, message: &[u8]) -> Result<…, IdentityError>identity.rs:190Ed25519 sign
fn verify(&self, message: &[u8], signature: &[u8]) -> Result<bool, IdentityError>identity.rs:202Ed25519 verify

Destination — a local or remote destination. Defined at reticulum-core/src/destination.rs. Re-exported as reticulum_std::Destination.

SignaturePurpose
fn new(identity: Option<Identity>, direction: Direction, dest_type: DestinationType, app_name: &str, aspects: &[&str]) -> Result<Self, DestinationError>destination.rs:285Construct a destination
fn hash(&self) -> &DestinationHashdestination.rs:336Its 16-byte hash
fn direction(&self) -> Directiondestination.rs:351In / 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.

TraitRequired methods (selected)Source
Clockfn now_ms(&self) -> u64traits.rs:162
Storagekey-value persistence: has_packet_hash, get_path/set_path, link/announce tables, identities, ratchets (large trait)traits.rs:196
Interfaceid, 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 the InterfaceId it arrived on.
  • handle_timeout()reticulum-core/src/node/mod.rs:1098. Run periodic maintenance (path expiry, announce rebroadcasts, keepalives, retransmissions). Call it at or before next_deadline.
  • next_deadline()reticulum-core/src/node/mod.rs:1129. The earliest timer deadline in milliseconds, or None if 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:

  1. next_deadline() drives the timer. Map None to “wait forever” (Instant::MAX) so you wake only when something actually needs doing — there is no fixed tick rate.
  2. InterfaceId(n) tags the source. The index you pass to handle_packet must match the interface’s own id(), so the core’s routing tables and broadcast-exclusion stay consistent.
  3. dispatch_actions does the routing. Rather than matching on each Action yourself, hand the whole actions vec plus your &mut dyn Interface slice to dispatch_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-core with default-features = false. No std, no tokio, alloc required.
  • Drive the loop: next_deadline() → wait for a packet or the deadline → handle_packet / handle_timeoutdispatch_actions(output.actions).
  • Implement Clock (trivial), Interface (send side only — you feed RX in via handle_packet), and pick a Storage (NoStorage / EmbeddedStorage / MemoryStorage, or your own).
  • The full worked driver is reticulum-nrf/src/bin/t114.rs; the full method list is cargo 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.

HandleRepresentsCreated byFreed by
leviculum_ta node (runtime, engine, event bridge)lev_builder_buildlev_free
lev_builder_tnode configuration before buildlev_builder_newlev_builder_free
lev_identity_ta key pair or public-only identitylev_identity_generate, lev_identity_from_*, lev_identity_load_file, lev_link_remote_identitylev_identity_free
lev_destination_ta local destinationlev_destination_newlev_destination_free
lev_link_tone link to a peerlev_connect, lev_connect_with_key, lev_accept_linklev_link_free
lev_event_tone drained eventlev_next_event, lev_wait_eventlev_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 buf and passes its capacity cap plus an out_len.
  • On success the library writes the bytes and sets *out_len to the count.
  • If cap is too small (or buf is NULL), nothing is written, *out_len is set to the required size, and the call returns LEV_ERR_BUFFER_TOO_SMALL. Passing buf == NULL is 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, request path) 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_t is 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 returns LEV_ERR_TIMEOUT. The link data path is try_send-first: lev_link_try_send never blocks and returns LEV_ERR_AGAIN under backpressure, while lev_link_send retries 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 embedded block_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_send is the reliable, sequenced send; it blocks up to its deadline, retrying backpressure internally. (The non-blocking sibling is lev_link_try_send, which returns LEV_ERR_AGAIN instead of waiting — see How-To: links and exchanging data.)
  • Receiving. lev_link_send on one side surfaces as a LEV_EVENT_LINK_MESSAGE on 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:

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.

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.

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 negative LEV_ERR_* on failure; constructors return NULL on 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 == NULL or too-small cap returns LEV_ERR_BUFFER_TOO_SMALL with *out_len set to the required size.
  • timeout_ms is a deadline in milliseconds; negative means wait forever; on expiry the call returns LEV_ERR_TIMEOUT.

Opaque types

TypeCreated byFreed byThread-safety
leviculum_tlev_builder_buildlev_freethread-safe; events single-consumer
lev_builder_tlev_builder_newlev_builder_freeone thread
lev_identity_tlev_identity_generate, lev_identity_from_private_key, lev_identity_from_public_key, lev_identity_load_file, lev_link_remote_identitylev_identity_freeone thread
lev_destination_tlev_destination_newlev_destination_freeone thread
lev_link_tlev_connect, lev_connect_with_key, lev_accept_linklev_link_freesends thread-safe; do not close/free concurrently with other calls on the same link
lev_event_tlev_next_event, lev_wait_eventlev_event_freeone thread
lev_path_table_tlev_path_table_snapshotlev_path_table_freeone thread
lev_interface_stats_tlev_interface_stats_snapshotlev_interface_stats_freeone 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_init runs one-time process setup (logging subscriber, panic hook) once, through an internal Once. Idempotent and thread-safe. Optional: other entry points run it lazily.
  • lev_log_set_level sets the global verbosity to one of the LEV_LOG_* constants. Returns LEV_ERR_INVALID_ARG for an out-of-range level.
  • lev_log_set_callback routes log records to cb (with user passed back unchanged), or restores the stderr default when cb is NULL. 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 any lev_* function.
ConstantValueMeaning
LEV_LOG_OFF0no logging (default)
LEV_LOG_ERROR1errors only
LEV_LOG_WARN2warnings and above
LEV_LOG_INFO3info and above
LEV_LOG_DEBUG4debug and above
LEV_LOG_TRACE5everything

Versioning

const char *lev_version_string(void);
uint32_t    lev_version_number(void);
  • lev_version_string returns the version (for example "0.7.0") as a static, never-freed string.
  • lev_version_number packs 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_strerror returns a static message for a LEV_ERR_* code; safe any time, never freed.
  • lev_last_error returns the thread-local detail string for the most recent failing call on the calling thread, or NULL if there is none. Owned by the library, valid until the next failing call on the same thread, never freed.

Error codes

ConstantValueMeaning
LEV_OK0success
LEV_ERR_NULL_PTR-1a required pointer argument was NULL
LEV_ERR_INVALID_ARG-2malformed argument (bad length, unparseable string)
LEV_ERR_BUFFER_TOO_SMALL-3caller buffer too small; *out_len holds the needed size
LEV_ERR_NOT_RUNNING-4the node event loop is not running
LEV_ERR_IO-5an I/O or storage error
LEV_ERR_CONFIG-6a configuration error
LEV_ERR_CRYPTO-7a cryptographic operation failed
LEV_ERR_NO_PATH-8no path to the destination is known
LEV_ERR_LINK-9a link operation failed (closed, inactive, handshake)
LEV_ERR_SEND-10a send failed (no route, payload too large)
LEV_ERR_RESOURCE-11a resource transfer operation failed
LEV_ERR_REQUEST-12a request or response operation failed
LEV_ERR_TIMEOUT-13the operation timed out
LEV_ERR_AGAIN-14non-fatal backpressure; retry later
LEV_ERR_UNKNOWN_DEST-15no cached identity for the destination
LEV_ERR_PANIC-127a 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_generate makes a new random full identity; NULL on failure.
  • lev_identity_from_private_key / lev_identity_from_public_key build an identity from a 64-byte combined key (len must equal LEV_IDENTITY_KEY_LEN); the public-key variant yields a public-only identity. NULL on failure.
  • lev_identity_load_file reads the raw 64-byte private key file (the Python-Reticulum format); NULL if missing, wrong size, or invalid.
  • lev_identity_save_file writes the private key to path atomically; LEV_ERR_CRYPTO if the identity is public-only.
  • lev_identity_hash writes the 16-byte identity hash; _public_key and _private_key write the 64-byte combined keys (_private_key returns LEV_ERR_CRYPTO for a public-only identity). All read(2) style.
  • lev_identity_has_private_keys returns 1 for a full identity, 0 otherwise (and 0 on NULL).
  • lev_identity_sign writes the 64-byte Ed25519 signature of msg read(2) style; LEV_ERR_CRYPTO if the identity is public-only. lev_identity_verify returns 1 if the signature is valid, 0 if not (including a wrong-length signature), and a negative LEV_ERR_* on a NULL argument; it needs only the public key.
  • lev_identity_encrypt encrypts plaintext to 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_decrypt reverses it with the private key and returns LEV_ERR_CRYPTO for 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.

ConstantValueMeaning
LEV_ADDR_LEN16destination, link, and identity hash length
LEV_IDENTITY_KEY_LEN64combined key length (public or private)
LEV_X25519_KEY_LEN32encryption half, bytes 0..32
LEV_SIGNING_KEY_LEN32Ed25519 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_new allocates a builder; lev_builder_free releases it (lev_builder_free(NULL) is a no-op).
  • The setters configure the node: _identity pins a (cloned) identity, _storage_path sets the state directory, the _add_* calls add interfaces (TCP addresses are host:port), _enable_transport toggles relay mode, and _event_capacity sets the event-queue sizes (control and data planes; a 0 keeps the current default). Each setter returns LEV_ERR_INVALID_ARG if the builder was already consumed.
  • lev_builder_link_keepalive overrides 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 making LEV_EVENT_LINK_STALE observable quickly. The same knob is the keepalive_interval key in a config file.
  • lev_builder_add_rnode adds a LoRa interface over an RNode: port is the serial device, then the required radio settings (frequency and bandwidth in Hz, spreading_factor, coding_rate denominator, tx_power in dBm). lev_builder_add_serial adds a raw KISS serial interface: port, speed, databits, parity ("N", "E", or "O"), stopbits. Both return LEV_ERR_INVALID_ARG on 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 at lev_start, not when the setter runs.
  • lev_builder_config_file loads an RNS-style INI config (the same format rnsd/lnsd read) from path; 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_instance makes the node offer a shared instance under name: it opens a local IPC endpoint and the rnstatus/rnpath/rnprobe RPC server, so other local programs (and tools) attach to this one stack.
  • lev_builder_connect_shared_instance makes the node a client of a shared instance named name instead of bringing up its own interfaces, the way rncp/rnx attach to a running daemon. A NULL path or name returns LEV_ERR_INVALID_ARG.
  • lev_builder_build produces a leviculum_t and empties the builder; you still call lev_builder_free on the empty handle. NULL on failure.
  • lev_start spawns the event loop and brings up interfaces; lev_stop persists state and tears it down; a stopped node can be started again. lev_start on a running node returns LEV_ERR_CONFIG.
  • lev_is_running returns 1 while the loop runs (0 on NULL).
  • lev_identity_hash_self writes the node’s own 16-byte identity hash.
  • lev_free stops 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_new builds a destination from an identity (may be NULL; required for some types, forbidden for LEV_DEST_PLAIN), a direction, a type, an app_name, and an array of n_aspects NUL-terminated aspect strings. NULL on failure.
  • lev_destination_hash writes the 16-byte hash; read it before registering. Returns LEV_ERR_INVALID_ARG once the destination has been consumed.
  • lev_destination_enable_ratchets turns on forward secrecy for an inbound destination before it is registered; now_ms is the current time in milliseconds, seeding ratchet rotation. LEV_ERR_INVALID_ARG for an outbound destination or one already registered. lev_destination_ratchet_public reads the current 32-byte ratchet public key of a registered destination (read(2) style), or LEV_ERR_INVALID_ARG if it has no ratchets. Ratcheted destinations interoperate with Python peers.
  • lev_destination_set_proof_strategy sets, before registration, how a destination proves delivery of received packets: LEV_PROOF_NONE (default, never), LEV_PROOF_APP (emit LEV_EVENT_PACKET_PROOF_REQUESTED so the app decides, then calls lev_send_proof), or LEV_PROOF_ALL (auto-prove every packet, Python’s PROVE_ALL). lev_send_proof sends a delivery proof for the packet_hash from a proof-requested event; LEV_ERR_SEND if no return path exists.
  • lev_register_destination registers 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_ARG if already registered.
  • lev_announce broadcasts a registered destination (by 16-byte hash) on all interfaces, with optional app_data.
  • lev_send_datagram sends one unreliable packet to a destination and writes the 16-byte packet hash into out_hash. A path must be known (LEV_ERR_NO_PATH otherwise).
ConstantValueMeaning
LEV_DIRECTION_IN0incoming: receives announces, links, packets
LEV_DIRECTION_OUT1outgoing: a source address for sending
LEV_DEST_SINGLE0point-to-point, ephemeral encryption
LEV_DEST_GROUP1shared-key broadcast
LEV_DEST_PLAIN2unencrypted
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_path returns 1 if a path to the destination is known, else 0 (negative on a NULL argument). lev_hops_to writes the hop count into *out or returns LEV_ERR_NO_PATH. lev_request_path asks the network for a path; the result arrives as an event and lev_has_path then returns 1.
  • lev_connect opens a link by destination hash, resolving the peer’s signing key from the identity cached by an announce; *out receives the link. Returns LEV_ERR_UNKNOWN_DEST if no identity is cached and LEV_ERR_NO_PATH if no path is known (it does not auto-request one).
  • lev_connect_with_key is the same with an explicit 32-byte Ed25519 signing key, for out-of-band peers.
  • lev_accept_link accepts an incoming link request (16-byte link id from a LEV_EVENT_LINK_REQUEST event); *out receives the link.
  • lev_link_send sends data, retrying backpressure up to the deadline (then LEV_ERR_TIMEOUT); lev_link_try_send never blocks and returns LEV_ERR_AGAIN under backpressure. Inbound data arrives as LEV_EVENT_LINK_DATA.
  • lev_link_id writes the 16-byte link id. lev_link_is_closed returns 1 if closed (0 on NULL).
  • lev_link_identify proves an identity to the peer (who sees LEV_EVENT_LINK_IDENTIFIED); lev_link_remote_identity returns the peer’s identity as a new handle the caller frees, or NULL if the peer has not identified.
  • lev_close_link closes gracefully (idempotent); lev_link_free releases 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_handler registers a handler for path on a local destination. For LEV_REQUEST_POLICY_ALLOW_LIST, allow_identity_hashes is n_ids * 16 bytes of identity hashes; otherwise pass NULL, 0. Registering overwrites a previous handler for the same destination and path; there is no unregister.
  • lev_send_request sends a request on an established link to path and writes the 16-byte request id into out_request_id. data is the msgpack-encoded payload (NULL, 0 for none); response_timeout_ms is the request-response deadline. The response (LEV_EVENT_RESPONSE_RECEIVED) or a timeout (LEV_EVENT_REQUEST_TIMEOUT) arrives as an event.
  • lev_send_response replies to a received request (link id and request id from the LEV_EVENT_REQUEST_RECEIVED event); data must be one valid msgpack-encoded value.
ConstantValueMeaning
LEV_REQUEST_POLICY_ALLOW_NONE0drop all requests
LEV_REQUEST_POLICY_ALLOW_ALL1allow any identity
LEV_REQUEST_POLICY_ALLOW_LIST2allow 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_resource sends bulk data over a link and writes the 32-byte resource hash into out_hash. metadata, if present, must be msgpack-encoded; auto_compress is 0 or 1. The call blocks only for the initial dispatch; progress and completion arrive as events.
  • lev_set_resource_strategy sets how incoming resources on a link are handled (one of the LEV_RESOURCE_* constants).
  • lev_accept_resource / lev_reject_resource answer a LEV_EVENT_RESOURCE_ADVERTISED event under the AcceptApp strategy.
ConstantValueMeaning
LEV_RESOURCE_ACCEPT_NONE0reject all incoming resources
LEV_RESOURCE_ACCEPT_ALL1accept all automatically
LEV_RESOURCE_ACCEPT_APP2advertise to the app to accept or reject
LEV_RESOURCE_HASH_LEN32resource 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_fd returns the readable fd to add to a poll/epoll/select loop. The library owns it and closes it in lev_free; never close it.
  • lev_next_event dequeues without blocking: on success *out is an event handle, or NULL when the queue is empty. lev_wait_event blocks up to timeout_ms (negative forever); *out is NULL if 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 with lev_event_free.
  • lev_event_type returns the event’s LEV_EVENT_* type (0 on NULL).
  • 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). _progress writes a double in 0.0..1.0 for resource-progress events; _dropped_count writes the count of a LEV_EVENT_CONTROL_OVERFLOW event; _msgtype and _sequence write the message type and sequence of a LEV_EVENT_LINK_MESSAGE event. An accessor that does not apply to the event type returns LEV_ERR_INVALID_ARG.
  • lev_event_is_sender returns 1 on the sender side of a resource event (_PROGRESS/_COMPLETED/_FAILED), 0 on the receiver side (or for other events). A sender’s LEV_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

ConstantValueFields available
LEV_EVENT_OTHER0catch-all for events without a typed projection
LEV_EVENT_ANNOUNCE_RECEIVED1dest_hash, data (app_data)
LEV_EVENT_PATH_FOUND2dest_hash
LEV_EVENT_LINK_REQUEST3link_id, dest_hash
LEV_EVENT_LINK_ESTABLISHED4link_id
LEV_EVENT_LINK_CLOSED5link_id
LEV_EVENT_LINK_DATA6link_id, data
LEV_EVENT_PACKET_RECEIVED7dest_hash, data
LEV_EVENT_CONTROL_OVERFLOW8dropped_count
LEV_EVENT_REQUEST_RECEIVED9link_id, request_id, path, data
LEV_EVENT_RESPONSE_RECEIVED10link_id, request_id, data
LEV_EVENT_REQUEST_TIMEOUT11link_id, request_id
LEV_EVENT_RESOURCE_ADVERTISED12link_id, resource_hash
LEV_EVENT_RESOURCE_STARTED13link_id, resource_hash
LEV_EVENT_RESOURCE_PROGRESS14link_id, resource_hash, progress
LEV_EVENT_RESOURCE_COMPLETED15link_id, resource_hash, data, metadata
LEV_EVENT_RESOURCE_FAILED16link_id, resource_hash
LEV_EVENT_LINK_IDENTIFIED17link_id, data (16-byte identity hash)
LEV_EVENT_LINK_MESSAGE18link_id, data, msgtype, sequence (reliable channel)
LEV_EVENT_PACKET_PROOF_REQUESTED19dest_hash, data (32-byte packet hash)
LEV_EVENT_LINK_PROOF_REQUESTED20link_id, data (32-byte packet hash)
LEV_EVENT_LINK_DELIVERY_CONFIRMED21link_id, data (32-byte packet hash)
LEV_EVENT_LINK_STALE22link_id (link inactive past keepalive)
LEV_EVENT_LINK_RECOVERED23link_id (stale link resumed)
LEV_EVENT_PATH_LOST24dest_hash (path expired)
LEV_EVENT_PACKET_DELIVERY_CONFIRMED25data (16-byte packet hash)
LEV_EVENT_DELIVERY_FAILED26data (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_stats reads the node’s transport counters and the current path-table size into the out-parameters, the basis for an rnstatus-style view. Any out-pointer may be NULL to skip that counter; a NULL node returns LEV_ERR_NULL_PTR. The values are a point-in-time snapshot.
  • lev_path_table_snapshot returns an owned, frozen copy of the path table for an rnpath-style view, or NULL on a NULL node; free it with lev_path_table_free (NULL is a no-op). Because it is frozen, reads never race a changing table. lev_path_table_count gives the number of entries. lev_path_table_entry reads one entry by index into the out-parameters: dest_hash and next_hop (each at least LEV_ADDR_LEN bytes when non-NULL), hops, has_next_hop (1 for a relayed path, 0 for a direct one), interface_index, and expires_ms. Any out-pointer may be NULL; LEV_ERR_INVALID_ARG if index is out of range.
  • lev_interface_stats_snapshot returns an owned, frozen copy of the interface list for an rnstatus-style interface view, freed with lev_interface_stats_free. lev_interface_stats_count gives the number of interfaces. lev_interface_stats_name reads the interface name read(2) style (variable length), and lev_interface_stats_entry reads the scalar fields (online, is_local_client, rx_bytes, tx_bytes) into out-parameters. Both return LEV_ERR_INVALID_ARG for 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_encode writes 2 * len lowercase hex bytes (not NUL-terminated), read(2) style.
  • lev_hex_decode writes hex_len / 2 bytes; LEV_ERR_INVALID_ARG on 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.

ConstantValuePurpose
FEND0xC0Frame delimiter (start and end)
FESC0xDBEscape byte
TFEND0xDCEscaped FEND (after FESC)
TFESC0xDDEscaped 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 FIRST
  • 0xC0 -> 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):

PropertyKISS (RNode)HDLC (TCP)
Delimiter0xC00x7E
Escape byte0xDB0x7D
Escape methodSubstitution (0xDC/0xDD)XOR with 0x20
CRCNoneNone (Reticulum simplified HDLC)
First byte after delimiterCommand bytePayload 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)

CommandByteDirectionPayloadDescription
CMD_DATA0x00BothRaw packet bytes (KISS-escaped)Reticulum packet data
CMD_FREQUENCY0x01Both4 bytes, big-endian, HzSet/report operating frequency
CMD_BANDWIDTH0x02Both4 bytes, big-endian, HzSet/report channel bandwidth
CMD_TXPOWER0x03Both1 byte, dBmSet/report TX power
CMD_SF0x04Both1 byte (5-12)Set/report spreading factor
CMD_CR0x05Both1 byte (5-8)Set/report coding rate (4/5 through 4/8)
CMD_RADIO_STATE0x06Both1 byte: 0x00=off, 0x01=on, 0xFF=askSet/report radio on/off state
CMD_RADIO_LOCK0x07Device->Host1 byteReport radio lock state
CMD_DETECT0x08BothHost sends 0x73, device responds 0x46Device presence detection handshake
CMD_LEAVE0x0AHost->Device0xFFHost is disconnecting (shutdown notification)
CMD_ST_ALOCK0x0BBoth2 bytes, big-endian, value/100 = percentShort-term airtime limit
CMD_LT_ALOCK0x0CBoth2 bytes, big-endian, value/100 = percentLong-term airtime limit
CMD_READY0x0FDevice->Host(none meaningful)Device ready for next TX packet

Statistics Commands (Device -> Host, unsolicited)

CommandByteDirectionPayloadDescription
CMD_STAT_RX0x21Device->Host4 bytes, big-endianTotal RX packet count
CMD_STAT_TX0x22Device->Host4 bytes, big-endianTotal TX packet count
CMD_STAT_RSSI0x23Device->Host1 byte (unsigned + 157 offset)Last packet RSSI
CMD_STAT_SNR0x24Device->Host1 byte (signed * 0.25 dB)Last packet SNR
CMD_STAT_CHTM0x25Device->Host11 bytes (see below)Channel time/utilization stats
CMD_STAT_PHYPRM0x26Device->Host12 bytes (see below)Physical layer parameters
CMD_STAT_BAT0x27Device->Host2 bytes: [state, percent]Battery status
CMD_STAT_CSMA0x28Device->Host3 bytes: [band, min, max]CSMA contention window params
CMD_STAT_TEMP0x29Device->Host1 byte (value - 120 = Celsius)CPU temperature

System Commands

CommandByteDirectionPayloadDescription
CMD_BLINK0x30Host->Device(unknown)Blink LED for identification
CMD_RANDOM0x40Device->Host1 byteHardware random byte
CMD_FB_EXT0x41Host->Device1 byte: 0x00=disable, 0x01=enableExternal framebuffer control
CMD_FB_READ0x42BothHost sends 0x01; Device responds with 512 bytesRead framebuffer
CMD_FB_WRITE0x43Host->Device[line_byte] + [8 bytes line data]Write framebuffer line
CMD_BT_CTRL0x46Host->Device(unknown)Bluetooth control
CMD_PLATFORM0x48BothHost sends 0x00; Device responds with platform byteQuery/report platform
CMD_MCU0x49BothHost sends 0x00; Device responds with MCU byteQuery/report MCU type
CMD_FW_VERSION0x50BothHost sends 0x00; Device responds with 2 bytes [major, minor]Query/report firmware version
CMD_ROM_READ0x51Host->Device(unknown)Read ROM data
CMD_RESET0x55BothHost sends 0xF8; Device sends 0xF8 on resetHard reset / reset notification
CMD_DISP_READ0x66BothHost sends 0x01; Device responds with 1024 bytesRead display buffer

Multi-Interface Commands (RNodeMultiInterface only)

CommandByteDirectionPayloadDescription
CMD_INTERFACES0x71BothHost queries; Device responds with 2 bytes per interface [vport, type]List available radio interfaces
CMD_SEL_INT0x1FHost->Device1 byte: interface indexSelect subinterface for next command
CMD_INT0_DATA0x00Device->HostPacket dataData received on interface 0
CMD_INT1_DATA0x10Device->HostPacket dataData received on interface 1
CMD_INT2_DATA0x20Device->HostPacket dataData received on interface 2
CMD_INT3_DATA0x70Device->HostPacket dataData received on interface 3
CMD_INT4_DATA0x75Device->HostPacket dataData received on interface 4
CMD_INT5_DATA0x90Device->HostPacket dataData received on interface 5
CMD_INT6_DATA0xA0Device->HostPacket dataData received on interface 6
CMD_INT7_DATA0xB0Device->HostPacket dataData received on interface 7
CMD_INT8_DATA0xC0Device->HostPacket dataData received on interface 8
CMD_INT9_DATA0xD0Device->HostPacket dataData received on interface 9
CMD_INT10_DATA0xE0Device->HostPacket dataData received on interface 10
CMD_INT11_DATA0xF0Device->HostPacket dataData 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)

ErrorByteDescription
ERROR_INITRADIO0x01Radio initialization failed
ERROR_TXFAILED0x02Transmission failed
ERROR_EEPROM_LOCKED0x03EEPROM is locked
ERROR_QUEUE_FULL0x04TX queue full (single-interface only)
ERROR_MEMORY_LOW0x05Memory exhausted (single-interface only)
ERROR_MODEM_TIMEOUT0x06Modem communication timeout (single-interface only)

Platform Constants

PlatformByteDescription
PLATFORM_AVR0x90AVR-based RNode
PLATFORM_ESP320x80ESP32-based RNode
PLATFORM_NRF520x70nRF52-based RNode

Radio Chip Types (Multi-Interface only)

ChipByteFrequency Range
SX127X0x00Sub-GHz (137 MHz - 1 GHz)
SX12760x01Sub-GHz
SX12780x02Sub-GHz
SX126X0x10Sub-GHz
SX12620x11Sub-GHz
SX128X0x202.4 GHz (2.2 GHz - 2.6 GHz)
SX12800x212.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:

  1. FEND CMD_DETECT DETECT_REQ(0x73) FEND – “Are you an RNode?”
  2. CMD_FW_VERSION 0x00 FEND – “What firmware version?”
  3. CMD_PLATFORM 0x00 FEND – “What platform?”
  4. 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:

  1. CMD_FREQUENCY with 4-byte big-endian frequency in Hz
  2. CMD_BANDWIDTH with 4-byte big-endian bandwidth in Hz
  3. CMD_TXPOWER with 1-byte TX power in dBm
  4. CMD_SF with 1-byte spreading factor (5-12)
  5. CMD_CR with 1-byte coding rate (5-8)
  6. CMD_ST_ALOCK with 2-byte short-term airtime limit (if configured)
  7. CMD_LT_ALOCK with 2-byte long-term airtime limit (if configured)
  8. CMD_RADIO_STATE with 0x01 (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 dBm
  • CMD_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:

  1. When flow_control=True is configured, the host sets interface_ready = False after sending each packet.
  2. The device sends a CMD_READY frame when it has finished transmitting and is ready for the next packet.
  3. Upon receiving CMD_READY, the host calls process_queue():
    • If packets are queued, pops the first one and sends it.
    • If no packets are queued, sets interface_ready = True.
  4. If interface_ready is False when process_outgoing() is called, the packet is appended to packet_queue instead 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 as byte - 256 if byte > 127 else byte on 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:

PlatformValueHas DisplayNotes
AVR0x90NoOriginal Arduino-based RNode
ESP320x80YesMost common modern RNode
NRF520x70YesNordic-based RNode

Radio chips (Multi-Interface only):

Chip FamilyFrequency RangeNotes
SX127X (SX1276/SX1278)137 MHz - 1 GHzSub-GHz LoRa
SX126X (SX1262)137 MHz - 1 GHzSub-GHz LoRa, newer
SX128X (SX1280)2.2 GHz - 2.6 GHz2.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 Serial object 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)
  • Requires device to be bonded (paired)
  • Read timeout: 1250ms
  • Detect wait: 5.0s
  • Uses bleak Python 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 time
  • CMD_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 percentage
  • CMD_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:

  1. Device-side CSMA (carrier sensing in firmware)
  2. Transport-layer announce scheduling (handled by Transport, not the interface)
  3. Announce rate cap (in base Interface class, based on bitrate):
    tx_time = (len(packet) * 8) / self.bitrate
    wait_time = tx_time / announce_cap
    
    Default announce_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_queue is an unbounded Python list (no max size)
  • FIFO ordering, no priority
  • interface_ready starts 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_lock
  • tcp_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:

KeyTypeRequiredDefaultDescription
namestringYesInterface name
portstringYesSerial port path, tcp://host, or ble://...
frequencyintYes0Operating frequency in Hz
bandwidthintYes0Channel bandwidth in Hz
txpowerintYes0TX power in dBm
spreadingfactorintYes0Spreading factor (5-12)
codingrateintYes0Coding rate (5-8)
flow_controlboolNoFalseEnable TX flow control
id_intervalintNoNoneCallsign beacon interval in seconds
id_callsignstringNoNoneCallsign for beaconing (max 32 bytes UTF-8)
airtime_limit_shortfloatNoNoneShort-term TX airtime limit (0-100%)
airtime_limit_longfloatNoNoneLong-term TX airtime limit (0-100%)

RNodeMultiInterface adds:

KeyTypeRequiredDefaultDescription
portstringYesSerial port path

Each sub-interface is defined as a nested section with:

KeyTypeRequiredDefaultDescription
interface_enabledboolNo(inherits parent enabled)Enable this sub-interface
vportintYesVirtual port index on device
frequencyintYesFrequency in Hz
bandwidthintYesBandwidth in Hz
txpowerintYesTX power in dBm
spreadingfactorintYesSpreading factor
codingrateintYesCoding rate
flow_controlboolNoFalseTX flow control
airtime_limit_shortfloatNoNoneShort-term airtime limit
airtime_limit_longfloatNoNoneLong-term airtime limit
outgoingboolNoTrueWhether TX is allowed

5.2 Connection Management

Startup:

  1. Validate configuration parameters
  2. Open serial port
  3. If open succeeds: configure_device (detect, init radio, validate)
  4. 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 online or detached
  • The readLoop also triggers reconnection when it catches an exception (serial port error, device reset, etc.)
  • ESP32 devices send CMD_RESET 0xF8 when they reset while online, which the host treats as an error triggering reconnection.

Shutdown (detach):

  1. Set self.detached = True
  2. Disable external framebuffer
  3. Set radio state to OFF
  4. Send CMD_LEAVE
  5. 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 percentages
  • r_channel_load_short / r_channel_load_long: Channel load percentages
  • r_battery_state / r_battery_percent: Battery info
  • r_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:

  1. Reads bytes from the serial port (async serial I/O)
  2. Parses the KISS frame state machine
  3. 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

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:

  1. RNode firmware: CSMA/CA with carrier sensing
  2. Transport layer: Announce rebroadcast random window (PATHFINDER_RW = 0.5s)
  3. 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:

PropertyHDLC (existing)KISS (needed for RNode)
Flag byte0x7E0xC0 (FEND)
Escape byte0x7D0xDB (FESC)
Escape methodXOR with 0x20Substitution: 0xDC (TFEND) or 0xDD (TFESC)
Command byteNoneFirst byte after FEND is command
Used forTCP interfacesSerial 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 KissDeframer with 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 KISSRNode 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:

  1. KISS framing module (framing/kiss.rs) – escape/unescape, frame/deframe
  2. RNode command parser – interpret command bytes and payloads
  3. Initialization sequence – detect, query, configure, validate
  4. Data path – TX: KISS-frame packets; RX: deframe and deliver
  5. Statistics – parse RSSI/SNR/channel stats from device
  6. Flow control – CMD_READY queue management
  7. Reconnection – handle disconnect/reconnect
  8. 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)

SymbolDescriptionMinMaxUnit
t1NSS falling to SCK setup time32-ns
t2SCK period62.5-ns
t6NSS falling to MISO delay015ns
t7SCK falling to MISO delay015ns
t8SCK to NSS rising hold time31.25-ns
t9NSS high time125-ns
t10NSS falling to SCK setup when switching from SLEEP to STDBY_RC100-us
t11NSS falling to MISO delay when switching from SLEEP to STDBY_RC0150us

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)

TransitionT_SW_Mode Typical (us)
SLEEP to STBY_RC cold start3500
SLEEP to STBY_RC warm start340
STBY_RC to STBY_XOSC31
STBY_RC to FS50
STBY_RC to RX83
STBY_RC to TX126
STBY_XOSC to TX105

8.4 Digital Interface Status versus Chip Modes (Table 8-3)

ModeDIO3DIO2DIO1BUSYMISOMOSISCKNSS
ResetPDPDPDPUHIZHIZHIZIN
Start-upPDPDPDPUHIZHIZHIZIN
SleepPDPDPDPUHIZHIZHIZIN
STBY_RCOUTOUTOUTOUTOUTINININ
STBY_XOSCOUTOUTOUTOUTOUTINININ
FS / RX / TXOUTOUTOUTOUTOUTINININ

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)

BitIRQDescriptionModulation
0TxDonePacket transmission completedAll
1RxDonePacket receivedAll
2PreambleDetectedPreamble detectedAll
3SyncWordValidValid Sync Word detectedFSK
4HeaderValidValid LoRa Header receivedLoRa
5HeaderErrLoRa Header CRC errorLoRa
6CrcErrWrong CRC receivedAll
7CadDoneChannel activity detection finishedLoRa
8CadDetectedChannel activity detectedLoRa
9TimeoutRx or Tx TimeoutAll

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)

ModeEnabled Blocks
SLEEPOptional registers, backup regulator, RC64k oscillator, data RAM
STDBY_RCTop regulator (LDO), RC13M oscillator
STDBY_XOSCTop regulator (DC-DC or LDO), XOSC
FSAll of the above + Frequency synthesizer at Tx frequency
TXFrequency synthesizer and transmitter, Modem
RXFrequency 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]Freq1Freq2
430 - 4400x6B0x6F
470 - 5100x750x81
779 - 7870xC10xC5
863 - 8700xD70xDB
902 - 9280xE1 (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)

Byte0[1:n]
Data from host (MOSI)OpcodeParameters
Data to host (MISO)RFUStatus

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)

CommandOpcodeParametersDescription
SetSleep0x84sleepConfigSet Chip in SLEEP mode
SetStandby0x80standbyConfigSet Chip in STDBY_RC or STDBY_XOSC mode
SetFs0xC1-Set Chip in Frequency Synthesis mode
SetTx0x83timeout[23:0]Set Chip in Tx mode
SetRx0x82timeout[23:0]Set Chip in Rx mode
SetCad0xC5-Set chip in RX mode with CAD parameters
SetTxContinuousWave0xD1-Test command: CW at selected frequency
SetRegulatorMode0x96regModeParamSelect LDO or DC_DC+LDO
Calibrate0x89calibParamCalibrate RC13, RC64, ADC, PLL, Image
CalibrateImage0x98freq1, freq2Image calibration at given frequencies
SetPaConfig0x95paDutyCycle, HpMax, deviceSel, paLUTConfigure PA
SetRxTxFallbackMode0x93fallbackModeMode after TX/RX done

11.2 Register and Buffer Access Commands (Table 11-2)

CommandOpcodeParameters
WriteRegister0x0Daddress[15:0], data[0:n]
ReadRegister0x1Daddress[15:0]
WriteBuffer0x0Eoffset, data[0:n]
ReadBuffer0x1Eoffset

11.3 DIO and IRQ Control (Table 11-3)

CommandOpcodeParameters
SetDioIrqParams0x08IrqMask[15:0], Dio1Mask[15:0], Dio2Mask[15:0], Dio3Mask[15:0]
GetIrqStatus0x12-
ClearIrqStatus0x02ClearIrqParam[15:0]
SetDIO2AsRfSwitchCtrl0x9Denable
SetDIO3AsTcxoCtrl0x97tcxoVoltage, timeout[23:0]

11.4 RF, Modulation and Packet Commands (Table 11-4)

CommandOpcodeParameters
SetRfFrequency0x86rfFreq[31:0]
SetPacketType0x8Aprotocol
SetTxParams0x8Epower, rampTime
SetModulationParams0x8BModParam1..8
SetPacketParams0x8C(preamble, header, payload, crc, iq)
SetBufferBaseAddress0x8FTX base address, RX base address
SetLoRaSymbNumTimeout0xA0SymbNum

13. Command Details (Selected)

13.1.2 SetStandby

Byte01
Data from host0x80StdbyConfig

StdbyConfig: 0 = STDBY_RC, 1 = STDBY_XOSC.

13.1.4 SetTx

Byte01-3
Data from host0x83timeout[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

Byte01-3
Data from host0x82timeout[23:0]
TimeoutDuration
0x000000Single mode: stays in RX until reception, then returns to STBY_RC
0xFFFFFFContinuous mode: remains in RX until host sends a mode change command
OthersTimeout active: returns to STBY_RC on timeout or reception. Max timeout is 262s.

13.1.11 SetRegulatorMode

Byte01
Data from host0x96regModeParam

regModeParam: 0 = Only LDO, 1 = DC_DC+LDO (used for STBY_XOSC, FS, RX and TX modes).

13.1.12 Calibrate Function

Byte01
Data from host0x89calibParam

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

Byte01234
Data from host0x95paDutyCyclehpMaxdeviceSelpaLut
  • 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 PowerpaDutyCyclehpMaxdeviceSelpaLutSetTxParams power
+22dBm0x040x070x000x01+22dBm
+20dBm0x030x050x000x01+22dBm
+17dBm0x020x030x000x01+22dBm
+14dBm0x020x020x000x01+22dBm

13.1.15 SetRxTxFallbackMode

Byte01
Data from host0x93fallbackMode
Fallback ModeValueDescription
FS0x40Go to FS mode after TX/RX
STDBY_XOSC0x30Go to STDBY_XOSC after TX/RX
STDBY_RC0x20Go to STDBY_RC after TX/RX (default)

13.2.1 WriteRegister

Byte0123n
MOSI0x0Daddr[15:8]addr[7:0]data@addrdata@addr+(n-3)
MISORFUStatusStatusStatusStatus

13.2.2 ReadRegister

Byte01234
MOSI0x1Daddr[15:8]addr[7:0]NOPNOP
MISORFUStatusStatusStatusdata@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

Byte012n
MOSI0x0Eoffsetdata@offsetdata@offset+(n-2)
MISORFUStatusStatusStatus

13.2.4 ReadBuffer

Byte0123
MOSI0x1EoffsetNOPNOP
MISORFUStatusStatusdata@offset

Note: An NOP must be sent after sending the offset.

13.3.1 SetDioIrqParams

Byte01-23-45-67-8
Data from host0x08IrqMask[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

Byte012-3
MOSI0x12NOPNOP
MISORFUStatusIrqStatus[15:0]

13.3.4 ClearIrqStatus

Byte01-2
MOSI0x02ClearIrqParam[15:0]

13.3.5 SetDIO2AsRfSwitchCtrl

Byte01
MOSI0x9Denable

enable=1: DIO2 controls RF switch. DIO2=1 during TX, DIO2=0 otherwise.

13.3.6 SetDIO3AsTcxoCtrl

Byte012-4
MOSI0x97tcxoVoltagedelay[23:0]

tcxoVoltage (Table 13-35)

ValueOutput Voltage
0x001.6V
0x011.7V
0x021.8V
0x032.2V
0x063.0V
0x073.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

Byte01-4
MOSI0x86RfFreq[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

Byte012
MOSI0x8EpowerRampTime

power: -9 to +22 dBm (encoded as 0xF7 to 0x16) for high power PA (SX1262).

RampTimeValueTime (us)
SET_RAMP_10U0x0010
SET_RAMP_20U0x0120
SET_RAMP_40U0x0240
SET_RAMP_80U0x0380
SET_RAMP_200U0x04200
SET_RAMP_800U0x05800

13.4.5 SetModulationParams (LoRa)

Byte012345-8
MOSI0x8BSFBWCRLdOptunused (0x00)
  • ModParam1 = SF (Spreading Factor)
  • ModParam2 = BW (Bandwidth)
  • ModParam3 = CR (Coding Rate)
  • ModParam4 = LdOpt (Low Data Rate Optimization)

13.5.1 GetStatus

Byte01
MOSI0xC0NOP
MISORFUStatus

Status Byte Format (Table 13-76)

Bit 7Bits 6:4Bits 3:1Bit 0
ReservedChip modeCommand statusReserved

Chip mode:

ValueMode
0x0Unused
0x2STBY_RC
0x3STBY_XOSC
0x4FS
0x5RX
0x6TX

Command status:

ValueMeaning
0x0Reserved
0x2Data is available to host
0x3Command timeout
0x4Command processing error
0x5Failure to execute command
0x6Command TX done

13.5.2 GetRxBufferStatus

Byte0123
MOSI0x13NOPNOPNOP
MISORFUStatusPayloadLengthRxRxStartBufferPointer

13.5.3 GetPacketStatus (LoRa)

Byte01234
MOSI0x14NOPNOPNOPNOP
MISORFUStatusRssiPktSnrPktSignalRssiPkt
  • 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

AddressNameDescription
0x0740LoRaSyncwordLoRa sync word (2 bytes, MSB first). 0x1424=private, 0x3444=public.
0x0889TxModulationBW500 workaround (bit 2)
0x08ACRxGain0x94=power saving (default), 0x96=boosted gain
0x08D8TxClampConfigPA clamp workaround (bits 4:1 = 0xF)
0x0736IqPolarityInverted IQ workaround (bit 2)
0x0902RtcControlRTC stop (write 0x00 after Rx with timeout)
0x0944EventMaskClear timeout event (bit 1)
0x029FRetentionList countNumber of retention registers
0x02A0-0x02A1RetentionList[0]First retention register address (0x08AC)

Init Sequence Summary (from Semtech reference driver + RNode)

  1. Hardware reset (NRESET LOW 100us, HIGH, wait BUSY LOW)
  2. SetStandby(STBY_RC) [0x80, 0x00]
  3. SetRegulatorMode(DC_DC) [0x96, 0x01]
  4. SetDIO2AsRfSwitchCtrl(enable) [0x9D, 0x01]
  5. ClearDeviceErrors [0x07, 0x00, 0x00]
  6. SetDIO3AsTcxoCtrl(1.8V, timeout) [0x97, 0x02, t2, t1, t0]
  7. Calibrate(all) [0x89, 0x7F] — wait BUSY LOW (~3.5ms)
  8. CalibrateImage(863-870MHz) [0x98, 0xD7, 0xDB]
  9. SetPacketType(LoRa) [0x8A, 0x01]
  10. SetRfFrequency(freq) [0x86, f3, f2, f1, f0]
  11. SetPaConfig(0x04, 0x07, 0x00, 0x01) for +22dBm SX1262
  12. SetTxParams(power, ramp) [0x8E, pwr, ramp]
  13. SetBufferBaseAddress(0, 0) [0x8F, 0x00, 0x00]
  14. SetModulationParams(SF, BW, CR, LDRO) [0x8B, sf, bw, cr, ldro, 0,0,0,0]
  15. SetPacketParams(preamble, header, len, crc, iq) [0x8C, ...]
  16. Write LoRa sync word to register 0x0740
  17. Apply workarounds: TxClamp (0x08D8), BW500 (0x0889), IQ (0x0736)
  18. Set RxGain to 0x96 (boosted) at register 0x08AC

TX Sequence

  1. SetDioIrqParams(TxDone|Timeout on DIO1) [0x08, mask_hi, mask_lo, dio1_hi, dio1_lo, 0,0, 0,0]
  2. ClearIrqStatus(all) [0x02, 0xFF, 0xFF]
  3. WriteBuffer(0, payload) [0x0E, 0x00, data...]
  4. SetTx(timeout) [0x83, t2, t1, t0] — timeout=0 for no timeout
  5. Wait for DIO1 HIGH (TxDone IRQ)
  6. ClearIrqStatus(TxDone) [0x02, 0x00, 0x01]

RX Sequence

  1. SetDioIrqParams(RxDone|Timeout|CrcErr on DIO1)
  2. ClearIrqStatus(all) [0x02, 0xFF, 0xFF]
  3. SetRx(timeout) [0x82, t2, t1, t0]
  4. Wait for DIO1 HIGH
  5. GetIrqStatus — check RxDone vs Timeout vs CrcErr
  6. GetRxBufferStatus [0x13, ...] — get length + start pointer
  7. ReadBuffer(start, length) [0x1E, start, NOP, data...]
  8. GetPacketStatus [0x14, ...] — get RSSI, SNR
  9. ClearIrqStatus
  10. 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_NAME first. Comes from the literal string passed as the event field in a tracing::debug! call.
  • node= second. Value comes from LEVICULUM_EVENT_NODE environment variable; defaults to local.
  • All other keys appear alphabetically sorted between node= and t=.
  • 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:

  1. 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 like type, use the raw identifier r#type.

  2. Add a catalogue entry in reticulum-std/src/test_support/event_log.rs’s EVENT_CATALOG:

    #![allow(unused)]
    fn main() {
    EventSchema {
        name: "FOO",
        required_keys: &["iface", "dst", "hops", "len"],
    },
    }

    required_keys should list every field the call site sets. The subscriber checks that every catalogued event’s emission includes all required keys; missing keys produce a EVENT_SCHEMA_VIOLATION line 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 the node= 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 / jldiff filter 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 for jl in isolation.
  • jldiff_compare.rs — Phase B unit/integration tests for jldiff in 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_NAME first.
  • 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...]
FlagEffect
--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:

FormMeaning
key=valueexact match
key=*event has that key (any value)
key=prefix*value starts with prefix
t<N, t>N, t<=N, t>=Nnumeric 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

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)

#MethodCallerFrequencyEmbedded?
1has_packet_hashTransport (process_incoming)hot – every inbound packetESSENTIAL
2add_packet_hashTransport (9 sites: send/receive/proof/data)hot – every packetESSENTIAL
3remove_packet_hashDEAD CODE – 0 production callsneverdead

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)

#MethodCallerFrequencyEmbedded?
4get_pathTransport (19 sites)hotESSENTIAL
5set_pathTransport (4 sites)frequentESSENTIAL
6remove_pathNodeCore (1), Transport (3), RPC (1)sometimesESSENTIAL
7path_countNodeCore (1), Transport (1), Driver (1)rarelynice-to-have
8expire_pathsTransport (clean_path_states)periodicESSENTIAL
9earliest_path_expiryTransport (next_deadline)periodicESSENTIAL
10has_pathNodeCore (3), Transport (4), Driver (1)frequentESSENTIAL
11path_entriesTransport (2: path_table_entries, drop_all_paths_via)rarelynice-to-have
12get_path_stateTransport (1: path_is_unresponsive)sometimesnice-to-have
13set_path_stateTransport (3: mark_path_unresponsive/responsive)sometimesnice-to-have
14clean_stale_path_metadataTransport (clean_path_states)periodicnice-to-have
15remove_paths_for_interfaceNodeCore (1), Transport (1)rarelyESSENTIAL

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)

#MethodCallerFrequencyEmbedded?
16get_announceTransport (5 sites)sometimesESSENTIAL
17get_announce_mutNodeCore (1), Transport (2)sometimesESSENTIAL
18set_announceTransport (4 sites)sometimesESSENTIAL
19remove_announceTransport (2: check_announce_rebroadcasts)sometimesESSENTIAL
20announce_keysTransport (2: next_deadline, check_announce_rebroadcasts)periodicESSENTIAL
21get_announce_cacheNodeCore (1), Transport (3)sometimesESSENTIAL
22set_announce_cacheNodeCore (3), Transport (1)sometimesESSENTIAL
23clean_announce_cacheTransport (1: clean_path_states)periodicnice-to-have
24get_announce_rateTransport (1: check_announce_rate)sometimesOPTIONAL
25set_announce_rateTransport (3: check_announce_rate)sometimesOPTIONAL
26announce_rate_entriesTransport (1: rate_table_entries)rarelyOPTIONAL

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)

#MethodCallerFrequencyEmbedded?
27get_path_request_timeTransport (2: send_path_request rate limiting)sometimesESSENTIAL
28set_path_request_timeTransport (1)sometimesESSENTIAL
29check_path_request_tagTransport (1: handle_path_request dedup)sometimesESSENTIAL

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)

#MethodCallerFrequencyEmbedded?
30get_receiptTransport (4: get_receipt, mark_delivered, proof handling)sometimesOPTIONAL
31set_receiptTransport (3: create_receipt, create_receipt_with_timeout, mark_delivered)sometimesOPTIONAL
32remove_receipt0 production calls (only via expire_receipts)neverdead (direct)
33expire_receiptsTransport (1: check_receipt_timeouts)periodicOPTIONAL
34earliest_receipt_deadlineTransport (1: next_deadline)periodicOPTIONAL

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)

#MethodCallerFrequencyEmbedded?
35get_identityNodeCore (1: send_single_packet), Driver (1)sometimesESSENTIAL
36set_identityNodeCore (1: remember_identity)sometimesESSENTIAL

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)

#MethodCallerFrequencyEmbedded?
37get_link_entryTransport (3: forward_link_routed, process_data, is_for_local_client_link)sometimesRELAY ONLY
38get_link_entry_mutTransport (1: mark link validated on proof)rarelyRELAY ONLY
39set_link_entryTransport (1: handle_link_request – insert bidirectional route)rarelyRELAY ONLY
40remove_link_entry0 production calls (only via expire/cleanup)neverdead (direct)
41has_link_entryTransport (3: dedup exemptions, is_link_routed check)sometimesRELAY ONLY
42expire_link_entriesTransport (1: clean_link_table)periodicRELAY ONLY
43earliest_link_deadlineTransport (1: next_deadline)periodicRELAY ONLY
44remove_link_entries_for_interfaceNodeCore (1), Transport (1)rarelyRELAY ONLY
45get_reverseTransport (1: proof routing)sometimesRELAY ONLY
46set_reverseTransport (3: forward_packet, link-routed data, proof handling)sometimesRELAY ONLY
47remove_reverseTransport (1: proof routing)sometimesRELAY ONLY
48has_reverse0 production calls (default impl, test only)neverdead
49expire_reversesTransport (1: clean_reverse_table)periodicRELAY ONLY
50remove_reverse_entries_for_interfaceNodeCore (1), Transport (1)rarelyRELAY 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)

#MethodCallerFrequencyEmbedded?
51set_discovery_path_requestTransport (1: handle_path_request)sometimesRELAY ONLY
52get_discovery_path_requestTransport (3: handle/retry/send_discovery)sometimesRELAY ONLY
53remove_discovery_path_requestTransport (2: send_discovery_path_response)sometimesRELAY ONLY
54expire_discovery_path_requestsTransport (1: clean_path_states)periodicRELAY ONLY
55discovery_path_request_dest_hashesTransport (2: next_deadline, retry)periodicRELAY 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)

#MethodCallerFrequencyEmbedded?
56get_known_ratchetTransport (1: set_local_client ratchet replay)rarelyOPTIONAL
57remember_known_ratchetTransport (1: handle_announce), NodeCore (2: announce_destination, check_mgmt_announces)rarelyOPTIONAL
58has_known_ratchet0 production callsneverdead
59known_ratchet_count0 production calls (test only)neverdead
60expire_known_ratchetsTransport (1: clean_path_states)periodicOPTIONAL
61store_dest_ratchet_keysNodeCore (2: announce_destination, check_mgmt_announces)rarelyOPTIONAL
62load_dest_ratchet_keysNodeCore (1: register_destination)rarelyOPTIONAL

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)

#MethodCallerFrequencyEmbedded?
63add_local_client_destTransport (1: handle_announce for local client)rarelySHARED ONLY
64remove_local_client_destsTransport (1: set_local_client cleanup)rarelySHARED ONLY
65has_local_client_dest0 production calls (test only)neverdead
66set_local_client_known_destTransport (1: handle_announce)rarelySHARED ONLY
67has_local_client_known_dest0 production calls (test only)neverdead
68local_client_known_dest_hashesTransport (2: set_local_client, clean_path_states)rarelySHARED ONLY
69expire_local_client_known_destsTransport (1: clean_path_states)periodicSHARED 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)

#MethodCallerFrequencyEmbedded?
70flushDriver (2: save_persistent_state, auto_interface)rarelyOPTIONAL
71diagnostic_dumpDriver (1)rarelyOPTIONAL

Already have empty default implementations. No action needed.


Dead Code Summary

7 methods with zero production callers:

MethodNotes
remove_packet_hashDefined but never called anywhere
has_reverseOnly the default impl delegates to get_reverse; no external callers
remove_link_entryOnly called indirectly via expire_link_entries
remove_receiptOnly called indirectly via expire_receipts
has_known_ratchetTest-only
known_ratchet_countTest-only
has_local_client_known_destTest-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

CollectionLayoutSize
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: Storagezero 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:

ClassPacket typeScopeWho originates
Self-announcePacket.ANNOUNCEBroadcastDestination.announce()
Forwarded announcePacket.ANNOUNCEBroadcastTransport relay on received announce
Path-requestPacket.DATA with transport_type = BROADCASTBroadcastTransport.request_path() or client call
Path-responsePacket.ANNOUNCE with context = PATH_RESPONSETargetedTransport answering a path-request
Link-requestPacket.LINKREQUESTUnicastLink.__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:

ConstantValueCitation
PATHFINDER_R1Transport.py:67
PATHFINDER_G5 sTransport.py:68
PATHFINDER_RW0.5 sTransport.py:69
LOCAL_REBROADCASTS_MAX2Transport.py:76

Deterministic walk — non-local-client source

Entry inserted with retries = 0, retransmit_at = now + rand*0.5s.

Tickretries inGuard AGuard BActionretries out
100 > 0 && 0 >= 2 = false0 > 1 = falsefire, schedule next1
211 > 0 && 1 >= 2 = false1 > 1 = falsefire, schedule next2
322 > 0 && 2 >= 2 = trueremove

Count = 2 rebroadcasts per received non-local-client announce.

Deterministic walk — local-client source

Entry inserted with retries = 1, retransmit_at = now.

Tickretries inGuard AGuard BActionretries out
111 > 0 && 1 >= 2 = false1 > 1 = falsefire, schedule next2
222 > 0 && 2 >= 2 = trueremove

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:

  1. Active answer: Transport receives a path-request, has the path, calls Destination.announce(path_response=True, tag=...) with the matching identity. This produces a Packet.ANNOUNCE with context = PATH_RESPONSE (Destination.py:309-310, 319-322) and sends it once.
  2. Rebroadcast with block_rebroadcasts: the retry loop at Transport.py:519-540 emits path-responses when announce_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.

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)

ItemValueCitation
Storageset()Transport.py:99
Previous-window storageset()Transport.py:100
Max size1 000 000 entriesTransport.py:145
Check siteline 1227Transport.py
Rotationhalf-cleared when reaches hashlist_maxsize/2approximate, 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

ConstantValueCitation
Reticulum.ANNOUNCE_CAP2 (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 when retries >= LOCAL_REBROADCASTS_MAX.
  • Transport.py:1588: secondary site that removes an entry from announce_table when 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

ConstantValueCitation
mgmt_announce_interval7 200 s (2 h)Transport.py:162
Initial-fire tricklast_mgmt_announce = now - interval + 15Transport.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):

ModeConstantIntent
MODE_FULL0x01Default. Fully participating transport node.
MODE_POINT_TO_POINT0x02Directed link, no announce flooding.
MODE_ACCESS_POINT0x03Gateway to clients. Special path expiry.
MODE_ROAMING0x04Mobile node. Selective rebroadcast.
MODE_BOUNDARY0x05Edge between mesh segments. Selective rebroadcast.
MODE_GATEWAY0x06Inter-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.

MechanismPython referenceRust todayStatusNotes
Self-announce one-shotDestination.py:322, Packet.py:294transport.rs:1256-1280 schedules 3 retries beyond the initialFixed in B3
Self-announce on-wire count14 (1 + 3 retries)B3 brings to 1
Self-announce fanoutall interfaces (MODE_FULL assumed)send_on_all_interfaces(exclude=None)transport.rs:1243-1255
Received-announce rebroadcast count2 (non-local-client), 1 (local-client)4 at retries=1 init + PATHFINDER_RETRIES=3B2 brings to 2
Received-announce fanoutall interfaces; echo dedup’d on RXsend_on_all_interfaces (no exclude)Matches Python. B1 verified by test_announces_forwarded_through_transport.
Packet-hash dedup on RXTransport.py:1227transport.rs:1179Identical semantics, rolling window
PATHFINDER_G grace5 s5 000 msconstants.rs:117
PATHFINDER_RW jitter0.5 s500 ms (+ optional airtime factor)Option α permitted timing divergence
LOCAL_REBROADCASTS_MAX22constants.rs:133; enforcement at transport.rs:3945
ANNOUNCE_CAP2 %2 %constants.rs:246; impl at transport.rs:287-296, 4125
announce_queue / deferred-sendinterface.announce_queueInterfaceAnnounceCap.queueSame intent, Rust-side uses Vec
mgmt_announce_interval7 200 s7 200 000 msconstants.rs:148; node/mod.rs:988-1048
mgmt-announce initial 15 s trickTransport.py:247node/mod.rs:75 + constantVerified by B4 audit
mgmt-announce iterates all destsPython walks mgmt_destinationscheck_mgmt_announces walks mgmt_destinationsVerified by B4 audit
Path-request one-shot broadcastTransport.py:2541-2587transport.rs (to verify in B7)B7 audit
Path-response targetedtransport.rs:4055-4070same mechanismPreserved
Interface modes (FULL/ROAMING/…)5 modesnone (all = FULL)Documented gap; separate task
block_rebroadcastsper-entry flagAnnounceEntry.block_rebroadcastsVerified 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 = 1 and change the entry-insert at transport.rs:1973-1974 from retries: 1 to retries: 0. Guards at transport.rs:3944-3945 already read retries > PATHFINDER_RETRIES and local_rebroadcasts >= LOCAL_REBROADCASTS_MAX; both fire at the right count.
  • B. Set PATHFINDER_RETRIES = 2 and leave insert at retries: 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 under reticulum-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

TierWhenCommandTimeScope
0on git push (hook)just fast~3 minfmt + clippy + workspace lib tests
1after git commit (hook, background)just standard~15 min (40 min cold1)Tier 0 + core/tests + ffi + proxy + rnsd_interop
212:30 & 18:30 daily (systemd timer)just extensive30–90 minTier 1 + Docker integ suite
302:00 daily (systemd timer)just nightly2–6 hTier 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 extensive once to clear, or git push --no-verify to 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.

  1. The CI uses its own CARGO_TARGET_DIR at ~/.cache/leviculum-ci-target so IDE builds and CI builds don’t fight over the same incremental cache. The first Tier 1 run after install-ci.sh compiles 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

TierNameTriggerBudgetTest scope
0fastpre-push hook~3 minfmt + clippy (host + nrf firmware workspace, both BSPs) + rustdoc gate + workspace lib tests
1standardpost-commit (background)~15 min (first run: 20-40 min cold compile)Tier 0 + core/tests + ffi + proxy + rnsd_interop
2extensiveon demand: systemctl --user start leviculum-ci-tier2.service~30-90 minTier 1 + integ Docker tests
3nightlysystemd timer 02:00 daily~2-6hTier 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:

  1. The git-hook wiring (core.hooksPath = .githooks) is skipped. The VM never commits or pushes; hooks would never fire.
  2. A worktree-scoped marker file (.git/worktrees/<name>/leviculum-ci-vm-mode-marker) is created. run-tier2.sh and run-tier3-hw.sh check this marker at the head of every run and, if present, invoke _repo-sync.sh to do git 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/

FileContents
last-results.txtone-line tally per run (<iso-timestamp> <tier> GREEN/RED <log-path>)
tier1-YYYYMMDD-HHMMSS-PID.logfull Tier 1 output (one file per run)
tier2-YYYYMMDD-HHMMSS-PID.logfull Tier 2 output
nightly-YYYYMMDD-HHMMSS-PID.logfull Tier 3 output
tier1.lockflock for Tier 1 concurrency control
tier1.dirtymarker 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 test with default behavior (skips ignored).
  • Tier 3 adds --include-ignored to 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 descriptorProfileActive devices
lora_lncp_bidir.tomllora_lncp_bidirt-beam-1, t-beam-2
lora_lnode_lncp_bidir.tomllora_lnode_lncp_bidirpocket-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

SymptomAction
post-commit looks dead`ps -ef
Notification never arrivedCheck last-results.txt. On headless boxes notifications are dropped.
Tier 1 spuriously redCheck log; if Docker is involved, ensure no leftover containers (docker ps -a)
Timer didn’t firesystemctl --user list-timers, then journalctl --user -u leviculum-ci-tier2.timer
Stale-block annoyinggit push --no-verify (one-shot) or run just extensive
Disk filling upLogs 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:line citation 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.py and 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

Reference

This specification describes Reticulum as implemented by the pinned reference:

ComponentVersionCommit
Reticulum (RNS)1.3.5d5e62d4e15c5fe2e170f7bd9e120551671f21a27

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 is name(16).
  • Test vectors are referenced by label [VEC-...] and listed in full in Test vectors; machine-readable in vectors/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 over x, 32 bytes (Identity.HASHLENGTH = 256 bits, Identity.py:80,373).
  • truncated_hash(x) is the leading 16 bytes of full_hash(x) (TRUNCATED_HASHLENGTH = 128 bits, 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; exchange is 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) and verify(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-CBC of one block "0123456789abcdef" under key 00010203…1f and IV 000102…0f is e23fc0b91c7bd64425c559736e9b0c58, 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 over m, 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:

  1. generate an ephemeral X25519 key pair (Identity.py:836);
  2. shared = ephemeral_private.exchange(target_X25519_public) (:844);
  3. derived_key = hkdf(length=64, derive_from=shared, salt=target_identity_hash, context=None) (:846-851);
  4. 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

TypeValueEncryptionCitation
SINGLE0x00per-recipient ECIES token to the identity (or ratchet)Destination.py:63
GROUP0x01symmetric (shared key, not auto-distributed):64
PLAIN0x02none (cleartext):65
LINK0x03per-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). Only IN SINGLE 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):

HexNameUsed by
0x00NONEgeneric data
0x01RESOURCEresource part
0x02RESOURCE_ADVresource advertisement
0x03RESOURCE_REQresource part request
0x04RESOURCE_HMUresource hashmap update
0x05RESOURCE_PRFresource proof
0x06RESOURCE_ICLresource initiator cancel
0x07RESOURCE_RCLresource receiver cancel
0x08CACHE_REQUESTcache request
0x09REQUESTlink request (application)
0x0ARESPONSElink response
0x0BPATH_RESPONSEtransport path response
0x0CCOMMANDcommand
0x0DCOMMAND_STATUScommand status
0x0ECHANNELchannel data
0xFAKEEPALIVElink keepalive
0xFBLINKIDENTIFYlink identification
0xFCLINKCLOSElink close
0xFDLINKPROOF(deprecated)
0xFELRRTTlink RTT
0xFFLRPROOFlink 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):

  1. parsing public_key = data[:64], then name_hash, random_hash, optional ratchet (when the context flag is set), signature, and app_data at the offsets above (Identity.py:546-564);
  2. reconstructing signed_data and verifying the signature against the transmitted public key (Identity.py:579);
  3. recomputing expected_destination_hash = truncated_hash(name_hash || identity_hash) and checking it matches (Identity.py:584-585);
  4. 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].

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.

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.

Once active, link packets are DATA packets addressed by link id, encrypted with the session key, distinguished by context byte:

ContextHexEncryptedPayloadCitation
LRPROOF0xFFnosig(64) + eph_pub(32) + signalling(3)Link.py:371-377
LRRTT0xFEyesmsgpack(float rtt)Link.py:440
LINKIDENTIFY0xFByesidentity_public(32) + sign(link_id||public)(64)Link.py:459-471
KEEPALIVE0xFAnosingle byte 0xFFLink.py:848-851
LINKCLOSE0xFCyeslink_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.

The sender advertises a resource with a RESOURCE_ADV packet (context 0x02) carrying a msgpack dictionary (ResourceAdvertisement.pack, Resource.py:1333-1355):

KeyMeaning
ttransfer size (encrypted bytes)
dtotal uncompressed data size
nnumber of parts
hresource hash (32)
rrandom hash (4)
ooriginal (first-segment) hash (32)
isegment index
ltotal segments
qassociated request id, or nil
fflags byte
mhashmap 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 abcd0007000b6368616e6e656c64617461abcd 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 810273747265616d646174618102 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 to LOCAL_REBROADCASTS_MAX = 2 local rebroadcasts, after a grace PATHFINDER_G = 5 s plus random jitter PATHFINDER_RW = 0.5 s (Transport.py:63-77).
  • Path TTLs. Default PATHFINDER_E = 7 days; access-point paths AP_PATH_TIME = 1 day; roaming paths ROAMING_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 s minimum 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 7E7D5E, 02, escaped 7D7D5D, 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)

ConstantValueLine
MTU50093
MDU464152
TRUNCATED_HASHLENGTH128 bits145
HEADER_MINSIZE19147
HEADER_MAXSIZE35148
IFAC_MIN_SIZE1149

Identity (Identity.py)

ConstantValueLine
KEYSIZE512 bits59
RATCHETSIZE256 bits64
TOKEN_OVERHEAD4877
HASHLENGTH256 bits80
SIGLENGTH512 bits81
NAME_HASH_LENGTH80 bits83
DERIVED_KEY_LENGTH6490

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.

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:lineClassSectionProof
MTU, MDU, HEADER sizes, TRUNCATED_HASHLENGTHReticulum.py:93-152N04computed (vector constants)
IFAC_MIN_SIZE, IFAC_SALTReticulum.py:149-150N10quoted
full/truncated hashIdentity.py:373-390N01vector VEC-HASH
HKDF, HMAC, AES, PKCS7Cryptography/*N01vector VEC-HKDF/HMAC/AES
Token (modified Fernet), TOKEN_OVERHEADToken.pyN01,02vector VEC-ID-TOKEN
X25519, Ed25519Cryptography/*N01,02vector VEC-ID-SIGN/LINK

Identity.py

Symbol(s)file:lineClassSectionProof
KEYSIZE, HASHLENGTH, SIGLENGTH, NAME_HASH_LENGTH, DERIVED_KEY_LENGTH, RATCHETSIZE59-90N02quoted/computed
key material, identity hash, get_public_key750-810N02vector VEC-ID-HASH
sign / validate931-964N02vector VEC-ID-SIGN
encrypt / decrypt (token)827-928N02vector VEC-ID-TOKEN
validate_announce532-634N05vector VEC-ANN-*
ratchet id / generation417-425N02quoted
RATCHET_EXPIRY, recall/remember69,—I02n/a (rotation/resolution)

Destination.py

Symbol(s)file:lineClassSectionProof
SINGLE/GROUP/PLAIN/LINK, PROVE_*, IN/OUT63-80N03,04quoted
name hash / destination hash116-141N03vector VEC-DEST-HASH
announce (data + signed data)243-317N05vector VEC-ANN-*
encrypt / decrypt585-611N03quoted (VEC-ID-TOKEN)
RATCHET_COUNT/INTERVAL, PR_TAG_WINDOW83-90I02n/a

Packet.py

Symbol(s)file:lineClassSectionProof
packet types, header types, context bytes, FLAG_*60-96N04quoted
ENCRYPTED_MDU / PLAIN_MDU106-110N04computed
pack / unpack177-272N04vector VEC-PKT-PLAIN/ENC/HEADER2
get_hashable_part, validate_proof, EXPL/IMPL_LENGTH355,498N04quoted
PacketReceipt states408-415I04n/a (local)

Link.py

Symbol(s)file:lineClassSectionProof
ECPUBSIZE, KEYSIZE, LINK_MTU_SIZE, masks, MODE_AES256_CBCN06quoted
link_id_from_lr_packet, set_link_id340-351N06vector VEC-LINK
handshake (session key), get_salt/get_context353-366,643N06vector VEC-LINK
prove, signalling_bytes371-377,148N06vector VEC-LINK
identify, send_keepalive, context payloads459,848N06quoted
states, KEEPALIVE/STALE timing, watchdogI06n/a

Resource.py

Symbol(s)file:lineClassSectionProof
ResourceAdvertisement.pack, flags, keys1278-1355N07vector VEC-RES-ADV
MAPHASH_LEN, RANDOM_HASH_SIZE, HASHMAP_*N07quoted
prove / validate_proof752-786N07vector VEC-RES-PROOF
context bytes RESOURCE..RESOURCE_RCLPacket.py:73-79N04,07quoted
WINDOW*, timeout factors, advertise/assembleI07n/a (flow control)
status enumI07n/a

Channel.py / Buffer.py

Symbol(s)file:lineClassSectionProof
Envelope.pack/unpackChannel.py:174-200N08vector VEC-CHAN-ENVELOPE
StreamDataMessage header, SMT_STREAM_DATA, STREAM_ID_MAX, OVERHEADBuffer.py:80-92N08vector VEC-STREAM-HDR
MessageState, CEType, sequencingI08n/a

Transport.py

Symbol(s)file:lineClassSectionProof
transmit / inbound (IFAC)1051-1434N10vector VEC-IFAC
request_path (path request)2771-2787N09vector VEC-PATH-REQUEST
path response (PATH_RESPONSE rebroadcast)2943-2972N09quoted
PATHFINDER_*, path TTLs, pacing50-83I09n/a (routing)
path/announce/link/reverse/tunnel tables, IDX_*3547-3586I09n/a (internal)
dedup, jobs, table culling508,—I09n/a

Interfaces

Symbol(s)file:lineClassSectionProof
HDLC FLAG/ESC/ESC_MASK, escapeTCPInterface.py:44-52,323N10vector VEC-HDLC
KISS FEND/FESC framingKISSInterface.pyN10quoted
interface drivers (TCP/LoRa/serial specifics)Interfaces/*Xn/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; truncated 659fe249468c635cdfe90a12624abec4.
  • 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 hash aca31af0441d81dbec71e82da0b4b5f5.
  • VEC-ID-SIGN (frozen): Ed25519 signature bbfdcde5aa05197f…, validate true.
  • VEC-ID-TOKEN (frozen-injection, Identity.py:827-928): 112-byte token efd9ec3449e46df2… = ephemeral_pub(32) || IV(16) || ciphertext || HMAC(32); decrypt(token) == plaintext.

Destination

  • VEC-DEST-HASH (frozen): app test, aspect vec → name hash 9da53eec82a28ce2f2e9 (10), destination hash 07d4541d4fdc0abfacc9364fdf979ee1 (16).

Packet

  • VEC-PKT-PLAIN (frozen): 0800fc0910664040482cd653166c8f225520006869 — flags 08 (PLAIN/DATA), hops 00, dest(16), context 00, 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 — flags 40 (HEADER_2), transport id(16), dest(16), context, data.

Announce

  • VEC-ANN-NORATCHET (frozen-injection): flags 01 (context flag 0); 150 data bytes 79a631eede1bf9c9… = pubkey(64) || name(10) || random(10) || sig(64) || app_data(2); validate_announce true.
  • VEC-ANN-RATCHET (frozen-injection): context flag 1; 182 data bytes including the 32-byte ratchet; validate_announce true.
  • VEC-LINK (frozen-injection, Link.py:308-366): request data a4e09292… = eph_x25519(32) || eph_ed25519(32) || signalling(3); link id 4725ac1375601d182afec3610f019b25; ECDH agreement true; 64-byte session key 569ac51a07fb242f… = hkdf(64, ecdh, salt=link_id).

Resource

  • VEC-RES-ADV (computed, Resource.py:1278-1355): advertisement dict with flags 03 (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 — type abcd, sequence 0007, length 000b, payload "channeldata".
  • VEC-STREAM-HDR (frozen): 810273747265616d64617461 — header 8102 (eof set over stream id 0x0102), data "streamdata".

Transport

  • VEC-PATH-REQUEST (frozen-injection, Transport.py:2780-2787): payload 000102…0f (target 16) || tag(16), in a PLAIN packet (flags 08).

Framing and IFAC

  • VEC-HDLC (frozen, TCPInterface.py:44-52): 01 7E 02 7D 03 frames to 7e017d5e027d5d037e.
  • VEC-IFAC (frozen, Transport.py:1051-1087): tag 2cf485c1dfcea002, masked output 9f4a2cf485c1dfcea0…, 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

ComponentVersionSubmodule commit
Reticulum (vendor/Reticulum)RNS 1.3.5d5e62d4e15c5fe2e170f7bd9e120551671f21a27

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

SymbolValueLineClass
MTU50093N
MDU464152N
TRUNCATED_HASHLENGTH128145N
HEADER_MINSIZE19147N
HEADER_MAXSIZE35148N
IFAC_MIN_SIZE1149N
IFAC_SALT32-byte hex150N

Cryptography/*

FileSymbol / methodLineClass
Token.pyToken (modified Fernet), TOKEN_OVERHEAD=48, encrypt, decrypt, verify_hmac50,77,87,100N
X25519.pyX25519PrivateKey/PublicKey, generate, exchange126,139N
Ed25519.pyEd25519PrivateKey/PublicKey, sign, verify53,69N
AES.pyAES_128_CBC, AES_256_CBC (16-byte IV, PKCS7)N
HMAC.pyRFC 2104 HMAC-SHA256, new, digestN
HKDF.pyhkdf(length, derive_from, salt, context)35N
PKCS7.pyblock size 16 paddingN

Identity.py (980 lines) — class Identity

Constants

SymbolValueLineClass
KEYSIZE51259N
RATCHETSIZE25664N
RATCHET_EXPIRY259200069I
TOKEN_OVERHEAD4877N
HASHLENGTH25680N
SIGLENGTH51281N
NAME_HASH_LENGTH8083N
TRUNCATED_HASHLENGTH12884N
DERIVED_KEY_LENGTH6490N

Methods

MethodLineClass
full_hash / truncated_hash373/383N
get_random_hash393N
_get_ratchet_id / _ratchet_public_bytes / _generate_ratchet417-425N
validate_announce532N
encrypt / decrypt827/872N
sign / validate931/948N
update_hashes / get_public_key / get_private_key808/757/750N
recall / rememberI (resolution)

Destination.py (680 lines) — class Destination

Constants

SymbolValueLineClass
SINGLE/GROUP/PLAIN/LINK0x00-0x0363-66N
PROVE_NONE/APP/ALL0x21-0x2369-71N
IN/OUT0x11/0x1279-80N
RATCHET_COUNT51285I
RATCHET_INTERVAL180090I
PR_TAG_WINDOW3083I

Methods

MethodLineClass
expand_name96N
hash / hash_from_name_and_identity116/141N
announce243N
encrypt / decrypt585/611N

Packet.py (603 lines) — class Packet, PacketReceipt

Constants

SymbolValueLineClass
packet types DATA/ANNOUNCE/LINKREQUEST/PROOF0x00-0x0360-63N
header types HEADER_1/HEADER_20x00/0x0167-68N
context bytes NONE..LRPROOF0x00-0xFF72-92N
FLAG_SET/FLAG_UNSET0x01/0x0095-96N
ENCRYPTED_MDU383106N
PLAIN_MDU=MDU (464)110N
PacketReceipt FAILED/SENT/DELIVERED/CULLED0,1,2,0xFF408-415I
EXPL_LENGTH/IMPL_LENGTH96/64N (proofs)

Methods

MethodLineClass
get_packed_flags169N
pack / unpack177/242N
get_hashable_part355N
validate_proof_packet / validate_proof443/498N

Constants

SymbolValueLineClass
ECPUBSIZE64N
KEYSIZE32N
LINK_MTU_SIZE3N
MTU_BYTEMASK0x1FFFFFN
MODE_BYTEMASK0xE0N
MODE_AES256_CBC0x01N
states PENDING..CLOSED0x00-0x04I
KEEPALIVE / STALE_TIME360/720I

Methods

MethodLineClass
link_id_from_lr_packet / set_link_id340/349N
handshake353N
prove / validate_proof371/396N
signalling_bytes / mtu_from_lp_packet / mode_from_lp_packet148+N
identify459N
send_keepalive848N
get_salt / get_context643/646N
watchdog, RTT scheduling, teardownI

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

SymbolValueLineClass
MAPHASH_LEN4N
RANDOM_HASH_SIZE4N
HASHMAP_IS_EXHAUSTED/NOT0xFF/0x00N
WINDOW*, *_TIMEOUT_FACTOR, MAX_RETRIESvariousI
OVERHEAD (advertisement)1341235N
status NONE..CORRUPT0x00-0x08I

Methods

MethodLineClass
ResourceAdvertisement.__init__ / pack / unpack1278/1333N
hashmap_update / request / receive_partN (wire) / I (scheduling)
prove / validate_proof752/782N
advertise / assemble / window adaptation508/672I

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)

SymbolLineClass
Envelope.pack/unpack (>HHH type,seq,len)192/179N
StreamDataMessage header (14-bit id, compressed, eof)Buffer 80-92N
SMT_STREAM_DATA 0xff00, STREAM_ID_MAX 0x3fff, OVERHEAD 8N
MessageState, CEType enumsI
channel sequencing/retransmissionI

Transport.py (3585 lines) — class Transport

Normative surfaces

SymbolLineClass
transmit (IFAC masking)1051N
inbound (IFAC unmasking)1398N
request_path (path request packet)2771N
path_request_handler / path response (PATH_RESPONSE rebroadcast)2866/2943N

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:line citation 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.py and pinned to the submodule commits. A citation proves “the code says this”; a vector proves “these are the bytes”.

Sections

Introduction and scope

Reference

This specification describes LXMF as implemented by the pinned reference:

ComponentVersionCommit
LXMF0.9.6 (_version.py:1)8499729024a4cddfceb47ca07188bb5b1d11d179
Reticulum (RNS)1.3.5d5e62d4e15c5fe2e170f7bd9e120551671f21a27

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 under vendor/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 as name(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 in vectors/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 over x, 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 of full_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 over m, 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 as source.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 encrypt for the propagated and paper forms over the message tail packed[16:] (LXMessage.py:427,446), and Destination.decrypt on 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

IdentifierWidthDefinitionCitation
Destination hash16Reticulum SINGLE destination hash of lxmf/deliveryLXMessage.py:39
Source hash16sender’s lxmf/delivery destination hashLXMessage.py:380-381
Signature64Ed25519 over the signed partLXMessage.py:40
Message hash / message-id32`full_hash(dest
Transient-id32full_hash(lxmf_data) for propagationLXMessage.py:431
Stamp32proof-of-work nonceLXStamper.py:13
Ticket16shared secret for stamp shortcutLXMessage.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:

Elementmsgpack typeCitation
timestampfloat64 (f64), seconds since the Unix epochLXMessage.py:354,359
titlebinary (bin), not stringLXMessage.py:190-193
contentbinary (bin), not stringLXMessage.py:199-202
fieldsmap, integer keys (may be empty {})LXMessage.py:212-216
stamp (optional)binary (bin), 32 bytesLXMessage.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)

KeyNameValue convention
0x01FIELD_EMBEDDED_LXMSlist of embedded LXM byte strings
0x02FIELD_TELEMETRYtelemetry blob
0x03FIELD_TELEMETRY_STREAMtelemetry stream blob
0x04FIELD_ICON_APPEARANCEappearance descriptor
0x05FIELD_FILE_ATTACHMENTSlist of [name, bytes]
0x06FIELD_IMAGE[format, bytes]
0x07FIELD_AUDIO[audio_mode, bytes] (see audio modes)
0x08FIELD_THREADthread reference
0x09FIELD_COMMANDSlist of commands
0x0AFIELD_RESULTSlist of results
0x0BFIELD_GROUPgroup metadata
0x0CFIELD_TICKET[expires, ticket], see Tickets
0x0DFIELD_EVENTevent payload
0x0EFIELD_RNR_REFSRNR references
0x0FFIELD_RENDERERrenderer hint (see renderers)
0xFBFIELD_CUSTOM_TYPEcustom type tag
0xFCFIELD_CUSTOM_DATAcustom data
0xFDFIELD_CUSTOM_METAcustom metadata
0xFEFIELD_NON_SPECIFICunspecified
0xFFFIELD_DEBUGdebug 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)

ValueName
0x00RENDERER_PLAIN
0x01RENDERER_MICRON
0x02RENDERER_MARKDOWN
0x03RENDERER_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

MethodValueCitation
OPPORTUNISTIC0x01LXMessage.py:29
DIRECT0x02LXMessage.py:30
PROPAGATED0x03LXMessage.py:31
PAPER0x05LXMessage.py:32
RepresentationValueCitation
UNKNOWN0x00LXMessage.py:24
PACKET0x01LXMessage.py:25
RESOURCE0x02LXMessage.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:

  1. If no method is desired, default to DIRECT (LXMessage.py:389-390).
  2. 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 to DIRECT (LXMessage.py:394-398). Otherwise representation is PACKET (LXMessage.py:401-412). For PLAIN destinations the limit is PLAIN_PACKET_MAX_CONTENT (368).
  3. DIRECT: if content size <= LINK_PACKET_MAX_CONTENT (319), representation is PACKET; otherwise RESOURCE (LXMessage.py:414-421).
  4. PROPAGATED: the message is wrapped into the propagation envelope (see Propagation); if the envelope size <= LINK_PACKET_MAX_CONTENT it is a PACKET, otherwise a RESOURCE (LXMessage.py:423-441).
  5. PAPER: if the encrypted paper form <= PAPER_MDU (2210) the representation is paper; otherwise pack() 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

MethodRepresentationOn-air bytesCitation
OPPORTUNISTICPACKETpacked[16:] (destination hash omitted)LXMessage.py:631
DIRECTPACKETfull packed over a LinkLXMessage.py:633
DIRECTRESOURCEfull packed as a Resource over a LinkLXMessage.py:650-651
PROPAGATEDPACKET/RESOURCEpropagation_packed to a propagation nodeLXMessage.py:634-635,652-653
PAPER(paper)lxm:// URI or QRLXMessage.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

  1. If there is no path to the destination, request one and wait (informative cadence). After MAX_PATHLESS_TRIES the message may be sent pathless.
  2. Send a single Reticulum Packet whose payload is packed[16:] (the destination hash is omitted; LXMessage.py:631).
  3. The message state becomes SENT. Delivery is confirmed by a Reticulum proof; on timeout the router re-queues up to MAX_DELIVERY_ATTEMPTS.

No link is established. Suitable only for messages within the single-packet content limit.

Direct

  1. Ensure a path, then establish a Reticulum Link to the destination’s lxmf/delivery endpoint.
  2. When the link is ACTIVE (LXMessage.py:647):
    • if representation is PACKET, send one Packet carrying the full packed bytes over the link (LXMessage.py:633);
    • if representation is RESOURCE, transfer packed as a Reticulum Resource over the link (LXMessage.py:650-651), with compression negotiated per the peer’s advertised support.
  3. On link failure before delivery, tear down and retry.

Propagated

  1. Establish a Link to the configured outbound propagation node.
  2. Send propagation_packed (the encrypted envelope, see Propagation) as a Packet or Resource depending on size (LXMessage.py:634-635,652-653).
  3. Success marks the message SENT (not DELIVERED): 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:

ContextRoundsWorkblock sizeCitation
Delivery stampWORKBLOCK_EXPAND_ROUNDS = 3000768 000 BLXStamper.py:10
Propagation stampWORKBLOCK_EXPAND_ROUNDS_PN = 1000256 000 BLXStamper.py:11
Peering keyWORKBLOCK_EXPAND_ROUNDS_PEERING = 256 400 BLXStamper.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_std discussion 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_cost in 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 with validate_stamp (LXMessage.py:270-291).
  • Propagation stamp: generated over the transient-id with WORKBLOCK_EXPAND_ROUNDS_PN and 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 is COST_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

ConstantValueSecondsCitation
TICKET_EXPIRY21 days1 814 400LXMessage.py:48
TICKET_GRACE5 days432 000LXMessage.py:49
TICKET_RENEW14 days1 209 600LXMessage.py:50
TICKET_INTERVAL1 day86 400LXMessage.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 as bin, or None (LXMRouter.py:991-992).
  • stamp_cost: an integer in (0, 255), or None (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 more lxmf_data blobs (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 want and have are None, the node returns a list of the recipient’s available transient_ids, sorted by size (LXMRouter.py:1436-1449);
  • otherwise have lists transient-ids the client already holds (so the node can drop them) and want lists the ones to send (LXMRouter.py:1451-).

Error codes (LXMPeer.py:24-31)

Returned by the node’s request handlers:

CodeNameMeaning
0xF0ERROR_NO_IDENTITYrequester did not identify on the link
0xF1ERROR_NO_ACCESSrequester not allowed
0xF3ERROR_INVALID_KEYinvalid peering key
0xF4ERROR_INVALID_DATAmalformed request
0xF5ERROR_INVALID_STAMPpropagation stamp invalid
0xF6ERROR_THROTTLEDrate limited (PN_STAMP_THROTTLE = 180 s)
0xFDERROR_NOT_FOUNDrequested message not found
0xFEERROR_TIMEOUTrequest 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)

ConstantValueMeaning
MAX_DELIVERY_ATTEMPTS5retries before a message fails
PROCESSING_INTERVAL4 sjobloop tick
DELIVERY_RETRY_WAIT10 swait between delivery attempts
PATH_REQUEST_WAIT7 swait after a path request
MAX_PATHLESS_TRIES1sends attempted before forcing a path request
LINK_MAX_INACTIVITY600 sidle link teardown
P_LINK_MAX_INACTIVITY180 sidle propagation link teardown

Expiry and limits

ConstantValueMeaning
MESSAGE_EXPIRY30 dayspropagation store retention
STAMP_COST_EXPIRY45 dayscached outbound stamp cost retention
PROPAGATION_LIMIT256 KBper-transfer propagation limit
SYNC_LIMIT256*40 KBper-sync cumulative limit
DELIVERY_LIMIT1000 KBdirect delivery resource limit
PN_STAMP_THROTTLE180 spropagation 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)

ConstantValueLine
APP_NAME“lxmf”1
SF_COMPRESSION0x00108

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)

ConstantValueLine
DESTINATION_LENGTH1639
SIGNATURE_LENGTH6440
TICKET_LENGTH1641
TIMESTAMP_SIZE860
STRUCT_OVERHEAD861
LXMF_OVERHEAD11262
ENCRYPTED_PACKET_MDU39167
ENCRYPTED_PACKET_MAX_CONTENT29578
LINK_PACKET_MDU43183
LINK_PACKET_MAX_CONTENT31989
PLAIN_PACKET_MDU46493
PLAIN_PACKET_MAX_CONTENT36894
QR_MAX_STORAGE2953104
PAPER_MDU2210105
URI_SCHEMA“lxm”102

Tickets (LXMessage.py:48-52)

ConstantValueSecondsLine
TICKET_EXPIRY21 days1 814 40048
TICKET_GRACE5 days432 00049
TICKET_RENEW14 days1 209 60050
TICKET_INTERVAL1 day86 40051
COST_TICKET0x100 (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)

ConstantValueLine
WORKBLOCK_EXPAND_ROUNDS300010
WORKBLOCK_EXPAND_ROUNDS_PN100011
WORKBLOCK_EXPAND_ROUNDS_PEERING2512
STAMP_SIZE3213
PN_VALIDATION_POOL_MIN_SIZE25614

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:lineClassSectionProof
representation UNKNOWN/PACKET/RESOURCE24-26N05quoted
method OPPORTUNISTIC/DIRECT/PROPAGATED/PAPER29-32N05quoted
unverified SOURCE_UNKNOWN/SIGNATURE_INVALID35-36N03quoted
size constants DESTINATION_LENGTH … PLAIN_PACKET_MAX_CONTENT39-94N02computed (vector constants)
ticket constants TICKET_EXPIRY/GRACE/RENEW/INTERVAL, COST_TICKET48-52N08quoted (vector constants)
URI_SCHEMA, QR_MAX_STORAGE, PAPER_MDU102-105N02, 05computed
ENCRYPTION_DESCRIPTION_*97-99In/a (local labels)
QR_ERROR_CORRECTION103In/a (QR rendering)
state constants14-21I06n/a (local lifecycle)
set_title/content_*, *_as_string190-205N03vector VEC-MSG-1/3
set_fields/get_fields212-218N03, 04vector VEC-MSG-2
validate_stamp270N07vector VEC-STAMP-1
get_stamp293N07quoted
get_propagation_stamp326N07, 10quoted
pack352N03, 05vector VEC-MSG-1/2
__as_packet623N05, 06vector VEC-DLV-OPP/DIRECT
__as_resource637N05, 06quoted
packed_container, write_to_directory, unpack_from_file657-810I/N11n/a (local storage)
as_uri687N05, 10vector VEC-PAPER-URI
as_qr707In/a (QR rendering)
unpack_from_bytes735N03vector VEC-MSG-3
send, __mark_*, __resource_concluded, timers460-620I06, 11n/a (orchestration)
msgpack sites 364/378/433/669/741/747N03, 10vector
time.time() 354/433N03, 10vector (timestamp fields)

LXMF.py

Symbol(s)file:lineClassSectionProof
APP_NAME1N00quoted
FIELD_* (all)8-41N04quoted (VEC-MSG-2 for one)
AM_* audio modes55-79N04quoted
RENDERER_*89-92N04quoted
PN_META_*98-104N09quoted (VEC-ANN-PROPAGATION)
SF_COMPRESSION108N12quoted
display_name_from_app_data, stamp_cost_from_app_data117-152N09vector VEC-ANN-DELIVERY
compression_support_from_app_data154N09quoted
pn_name_from_app_data, pn_stamp_cost_from_app_data, pn_announce_data_is_valid169-217N09vector VEC-ANN-PROPAGATION

LXStamper.py

Symbol(s)file:lineClassSectionProof
WORKBLOCK_EXPAND_ROUNDS*, STAMP_SIZE10-13N07quoted
stamp_workblock18N07vector VEC-STAMP-1
stamp_value31N07vector VEC-STAMP-1
stamp_valid42N07vector VEC-STAMP-1
validate_peering_key48N10quoted
validate_pn_stamp53N10quoted
generate_stamp92N07quoted
validate_pn_stamps*, job_*, cancel_work, PN_VALIDATION_POOL_MIN_SIZE67-354I07n/a (PoW parallelism)

Handlers.py

Symbol(s)file:lineClassSectionProof
LXMFDeliveryAnnounceHandler.received_announce9-32N09vector VEC-ANN-DELIVERY
LXMFPropagationAnnounceHandler.received_announce35-72N+I09, 11vector / n/a (auto-peer)

LXMRouter.py

Symbol(s)file:lineClassSectionProof
get_propagation_node_announce_metadata, get_propagation_node_app_data302-319N09vector VEC-ANN-PROPAGATION
get_announce_app_data986N09vector VEC-ANN-DELIVERY
generate_ticket, remember_ticket, get_outbound_ticket*1025-1086N08quoted
message_get_request/list_response/get_response1427-1591N10quoted
offer_request, propagation_packet, propagation_resource_concluded, lxmf_propagation, ingest_lxm_uri2110-2392N10quoted
lxmf_delivery1732N06quoted
PR_* states62-77N10quoted
request paths STATS/SYNC/UNPEER81-83I11n/a
delivery/expiry/peer/job constants30-83, 853-860I11n/a (scheduling)
persistence, queues, jobloop, rotation, time.time()variousI11n/a (internal)

LXMPeer.py

Symbol(s)file:lineClassSectionProof
OFFER_REQUEST_PATH, MESSAGE_GET_PATH14-15N10quoted
ERROR_*24-31N10quoted
generate_peering_key242N10quoted
sync (offer payload), offer_response, resource_concluded267-520N10quoted
state constants, strategy, timing, from_bytes/to_bytes, peer counts17-50, 52-175, 544-640I11n/a (internal/persistence)
msgpack 462 (sync resource)N10quoted

_version.py / Utilities/lxmd.py

Symbol(s)file:lineClassSectionProof
__version___version.py:1N00quoted (pin)
daemon/CLIlxmd.pyXn/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

ComponentVersionSubmodule commit
LXMF (vendor/LXMF)0.9.68499729024a4cddfceb47ca07188bb5b1d11d179
Reticulum (vendor/Reticulum)RNS 1.3.5d5e62d4e15c5fe2e170f7bd9e120551671f21a27

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

SymbolValueLineClass
state GENERATING/OUTBOUND/SENDING/SENT/DELIVERED/REJECTED/CANCELLED/FAILED0x00,0x01,0x02,0x04,0x08,0xFD,0xFE,0xFF14-21I
representation UNKNOWN/PACKET/RESOURCE0x00,0x01,0x0224-26N
method OPPORTUNISTIC/DIRECT/PROPAGATED/PAPER0x01,0x02,0x03,0x0529-32N
unverified SOURCE_UNKNOWN/SIGNATURE_INVALID0x01,0x0235-36N
DESTINATION_LENGTH1639N
SIGNATURE_LENGTH6440N
TICKET_LENGTH1641N
TICKET_EXPIRY/GRACE/RENEW/INTERVAL21d/5d/14d/1d48-51N
COST_TICKET0x10052N
TIMESTAMP_SIZE860N
STRUCT_OVERHEAD861N
LXMF_OVERHEAD11262N
ENCRYPTED_PACKET_MDUderived67N
ENCRYPTED_PACKET_MAX_CONTENT29578N
LINK_PACKET_MDURNS.Link.MDU83N
LINK_PACKET_MAX_CONTENT31989N
PLAIN_PACKET_MDURNS.Packet.PLAIN_MDU93N
PLAIN_PACKET_MAX_CONTENT36894N
ENCRYPTION_DESCRIPTION_AES/EC/UNENCRYPTEDstrings97-99I
URI_SCHEMA“lxm”102N
QR_ERROR_CORRECTION“ERROR_CORRECT_L”103I
QR_MAX_STORAGE2953104N
PAPER_MDU2210105N

Methods (wire-relevant marked N)

MethodLineClass
__init__113N (field defaults)
set_title_from_string/bytes, title_as_string190-196N
set_content_from_string/bytes, content_as_string199-205N
set_fields, get_fields212-218N
validate_stamp270N
get_stamp293N
get_propagation_stamp326N
pack352N
send460I
determine_compression_support507N
determine_transport_encryption517I
__mark_delivered/propagated/paper_generated558-582I
__resource_concluded, __propagation_resource_concluded594-605I
__link_packet_timed_out, __update_transfer_progress613-620I
__as_packet623N
__as_resource637N
packed_container657N
write_to_directory672I
as_uri687N
as_qr707I
unpack_from_bytes (static)735N
unpack_from_file (static)810I

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

SymbolValueLineClass
APP_NAME“lxmf”1N
FIELD_EMBEDDED_LXMS..FIELD_RENDERER0x01..0x0F8-22N
FIELD_CUSTOM_TYPE/DATA/META0xFB-0xFD34-36N
FIELD_NON_SPECIFIC/DEBUG0xFE-0xFF40-41N
AM_CODEC2_*0x01..0x0955-63N
AM_OPUS_*0x10..0x1966-75N
AM_CUSTOM0xFF79N
RENDERER_PLAIN/MICRON/MARKDOWN/BBCODE0x00-0x0389-92N
PN_META_VERSION..PN_META_CUSTOM0x00..0xFF98-104N
SF_COMPRESSION0x00108N

Functions

FunctionLineClass
display_name_from_app_data117N
stamp_cost_from_app_data141N
compression_support_from_app_data154N
pn_name_from_app_data169N
pn_stamp_cost_from_app_data182N
pn_announce_data_is_valid191N

msgpack sites

123, 146, 159, 173, 186, 194 (all unpack of announce app_data, N).


LXStamper.py (396 lines) — module

Constants

SymbolValueLineClass
WORKBLOCK_EXPAND_ROUNDS300010N
WORKBLOCK_EXPAND_ROUNDS_PN100011N
WORKBLOCK_EXPAND_ROUNDS_PEERING2512N
STAMP_SIZE3213N
PN_VALIDATION_POOL_MIN_SIZE25614I

Functions

FunctionLineClass
stamp_workblock18N
stamp_value31N
stamp_valid42N
validate_peering_key48N
validate_pn_stamp53N
validate_pn_stamps_job_simple/multip, validate_pn_stamps67-87I (parallelism)
generate_stamp92N (algorithm)
cancel_work113I
job_simple/linux/android145-260I (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 / methodLineClass
LXMFDeliveryAnnounceHandler.received_announce9/15N (announce parsing)
LXMFPropagationAnnounceHandler.received_announce35/41N + 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

SymbolLineClass
get_propagation_node_announce_metadata302N
get_propagation_node_app_data307N
get_announce_app_data986N
generate_ticket1025N
message_get_request1427N (/get request shape)
message_list_response1507N
message_get_response1552N
offer_request2142N (/offer handler)
propagation_packet2110N
propagation_resource_concluded2194N
lxmf_propagation2310N (transient ingest)
ingest_lxm_uri2370N (paper ingest)
lxmf_delivery1732N (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)

SymbolLineClass
OFFER_REQUEST_PATH="/offer", MESSAGE_GET_PATH="/get"14-15N
state IDLE..RESOURCE_TRANSFERRING17-22I
ERROR_NO_IDENTITY..ERROR_TIMEOUT24-31N (/offer response codes)
STRATEGY_LAZY/PERSISTENT, DEFAULT_SYNC_STRATEGY33-35I
MAX_UNREACHABLE=14d, SYNC_BACKOFF_STEP=12m, PATH_REQUEST_GRACE=7.539-50I
from_bytes/to_bytes (peer persistence)52/138I
generate_peering_key242N
sync267I + N (offer payload shape)
offer_response396N
resource_concluded488N (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.