Embedded Development: Building on reticulum-core
This chapter is for building on reticulum-core directly: embedded firmware, a
custom async runtime, a simulator, or any host program that wants byte-level
control without tokio. The core is no_std (it uses alloc, but not the
standard library) and sans-IO — it performs no I/O and owns no runtime. You feed
it bytes, it hands back a TickOutput,
and you do the I/O. The worked reference is the nRF52 firmware in reticulum-nrf,
cited throughout.
If you can use std and tokio, prefer reticulum-std and read the
tutorial instead — reticulum-std is itself a driver
for this same core. See Choosing a layer for the trade-off.
The dependency
Depend on reticulum-core with default features off. It is not on crates.io, so
use a path or git:
[dependencies]
reticulum-core = { path = "../libreticulum/reticulum-core", default-features = false }
No std, no tokio. You bring your own executor (Embassy, RTIC, a bare loop) and
your own allocator. The reference firmware reticulum-nrf is version 0.4.0,
targets thumbv7em-none-eabihf, and uses Embassy.
The sans-IO contract
The core is a state machine with exactly three ways in, and one way out. The way
out is always a TickOutput (reticulum-core/src/transport.rs:138), carrying
actions to perform, events that occurred, and next_deadline_ms, the time at
which you must next tick the timer. It is #[must_use]: dropping it loses
outbound packets and events.
received bytes ─► handle_packet(iface, data) ─┐
timer expired ─► handle_timeout() ├─► TickOutput { actions, events, next_deadline_ms }
│
└─► you: dispatch actions, react to events,
schedule the next timeout
The three entry points (signatures in the reference):
handle_packet(iface, data)—reticulum-core/src/node/mod.rs:1006. Feed one received frame, tagged with theInterfaceIdit arrived on.handle_timeout()—reticulum-core/src/node/mod.rs:1098. Run periodic maintenance (path expiry, announce rebroadcasts, keepalives, retransmissions). Call it at or beforenext_deadline.next_deadline()—reticulum-core/src/node/mod.rs:1129. The earliest timer deadline in milliseconds, orNoneif no timer is pending. Sleep until this, or until a packet arrives, whichever comes first.
App-initiated operations (register_destination, announce_destination,
connect, send_on_link, send_single_packet) likewise return a TickOutput
you must dispatch.
The driver loop
The shape is: compute the next deadline, wait for whichever of “a packet on any
interface” or “the deadline” happens first, call the matching entry point,
dispatch the resulting actions. This is exactly the reticulum-nrf T114 main
loop (reticulum-nrf/src/bin/t114.rs:256-307), here with three interfaces
(serial, LoRa, BLE) selected over with Embassy’s select4:
#![allow(unused)]
fn main() {
// Adapted from reticulum-nrf/src/bin/t114.rs:256
loop {
let deadline = node
.next_deadline()
.map(Instant::from_millis)
.unwrap_or(Instant::MAX);
match select4(
serial.incoming_rx.receive(),
lora_channels.incoming_rx.receive(),
ble_channels.incoming_rx.receive(),
Timer::at(deadline),
)
.await
{
Either4::First(data) => {
let output = node.handle_packet(InterfaceId(0), &data);
let mut ifaces: [&mut dyn Interface; 3] =
[&mut serial_iface, &mut lora_iface, &mut ble_iface];
dispatch_actions(&mut ifaces, output.actions, &ifac_configs);
}
Either4::Second(data) => {
let output = node.handle_packet(InterfaceId(1), &data);
let mut ifaces: [&mut dyn Interface; 3] =
[&mut serial_iface, &mut lora_iface, &mut ble_iface];
dispatch_actions(&mut ifaces, output.actions, &ifac_configs);
}
Either4::Third(data) => {
let output = node.handle_packet(InterfaceId(2), &data);
let mut ifaces: [&mut dyn Interface; 3] =
[&mut serial_iface, &mut lora_iface, &mut ble_iface];
dispatch_actions(&mut ifaces, output.actions, &ifac_configs);
}
Either4::Fourth(()) => {
let output = node.handle_timeout();
let mut ifaces: [&mut dyn Interface; 3] =
[&mut serial_iface, &mut lora_iface, &mut ble_iface];
dispatch_actions(&mut ifaces, output.actions, &ifac_configs);
}
}
}
}
Three things to notice:
next_deadline()drives the timer. MapNoneto “wait forever” (Instant::MAX) so you wake only when something actually needs doing — there is no fixed tick rate.InterfaceId(n)tags the source. The index you pass tohandle_packetmust match the interface’s ownid(), so the core’s routing tables and broadcast-exclusion stay consistent.dispatch_actionsdoes the routing. Rather than matching on eachActionyourself, hand the wholeactionsvec plus your&mut dyn Interfaceslice todispatch_actions(reticulum-core/src/transport.rs:211). Broadcast exclusion, interface selection, and IFAC wrapping live in core, so every driver gets them for free.
This loop ignores output.events because a leaf firmware node has no application
logic to react to them; a richer firmware would drain output.events here the
way the std event loop
drains the EventReceiver.
Building the node
NodeCoreBuilder (reticulum-core/src/node/builder.rs:38) takes the platform
triple — RNG, Clock, and
Storage — in its build call. From the
T114 firmware (reticulum-nrf/src/bin/t114.rs:123-142):
#![allow(unused)]
fn main() {
// Adapted from reticulum-nrf/src/bin/t114.rs:123
let mut builder = NodeCoreBuilder::new()
.enable_transport(true)
.max_incoming_resource_size(8 * 1024)
.respond_to_probes(true);
if let Ok(Some(identity)) = id_store.load() {
builder = builder.identity(identity);
}
let mut node = Box::new(builder.build(rng, EmbassyClock, EmbeddedStorage::new()));
}
build consumes the builder and the platform triple and returns the
NodeCore<R, C, S>.
Implementing the platform traits
Three traits decouple the core from your hardware. Their signatures are in the reference; here is what to supply.
Clock
A monotonic millisecond clock. The whole trait is one required method. The
nRF52 implementation wraps Embassy’s timer
(reticulum-nrf/src/clock.rs):
#![allow(unused)]
fn main() {
use reticulum_core::traits::Clock;
pub struct EmbassyClock;
impl Clock for EmbassyClock {
fn now_ms(&self) -> u64 {
embassy_time::Instant::now().as_millis()
}
}
}
now_secs, has_elapsed, and deadline have default implementations
(reticulum-core/src/traits.rs:167-179); you only provide now_ms. It must be
monotonic.
Interface
The send side of an interface — id, name, mtu, is_online, and the
non-blocking try_send (reticulum-core/src/traits.rs:97). The receive side is
deliberately not in the trait: receiving is platform-specific (an interrupt, a
DMA buffer, an Embassy channel), and you feed received bytes into the core via
handle_packet yourself. try_send returns InterfaceError::BufferFull
(non-fatal, packet dropped — Reticulum is best-effort) or
InterfaceError::Disconnected. A minimal always-ready interface looks like the
test impl in traits.rs:
#![allow(unused)]
fn main() {
use reticulum_core::traits::{Interface, InterfaceError};
use reticulum_core::transport::InterfaceId;
struct MyRadio { /* hardware handle */ }
impl Interface for MyRadio {
fn id(&self) -> InterfaceId { InterfaceId(1) }
fn name(&self) -> &str { "my-radio" }
fn mtu(&self) -> usize { 500 }
fn is_online(&self) -> bool { true }
fn try_send(&mut self, data: &[u8]) -> Result<(), InterfaceError> {
// hand `data` to the radio's TX queue, non-blocking
Ok(())
}
}
}
A constrained medium (LoRa) overrides next_slot_ms
(reticulum-core/src/traits.rs:150) to report the next airtime-fit time, so the
core schedules retries against capacity without knowing any radio physics — the
interface-isolation rule. For a fast link the default
(“always ready”) is correct.
Storage
Key-value persistence for the path table, link table, announce caches,
identities, ratchets, and dedup hashes (reticulum-core/src/traits.rs:196). It
is a large trait; you do not write it from scratch:
NoStorage(reticulum-core/src/traits.rs:506) — zero-sized, every lookup returns nothing. Use it for a stateless node or a smoke test.EmbeddedStorage(reticulum-core/src/embedded_storage.rs:37,EmbeddedStorage::new()at:344) —heapless-backed, fixed-capacity, the production choice for flash-constrained devices. This is what the nRF52 firmware uses.MemoryStorage(reticulum-core/src/memory_storage.rs) — BTreeMap-backed with configurable caps, for hosts with more memory.
Implement Storage yourself only to add real persistence (e.g. to flash); the
file-backed implementation in reticulum-std is the worked example of wrapping
MemoryStorage with disk writes.
Summary
- Depend on
reticulum-corewithdefault-features = false. Nostd, no tokio,allocrequired. - Drive the loop:
next_deadline()→ wait for a packet or the deadline →handle_packet/handle_timeout→dispatch_actions(output.actions). - Implement
Clock(trivial),Interface(send side only — you feed RX in viahandle_packet), and pick aStorage(NoStorage/EmbeddedStorage/MemoryStorage, or your own). - The full worked driver is
reticulum-nrf/src/bin/t114.rs; the full method list iscargo doc --no-deps -p reticulum-core(see the reference).