BoomLink¶
BoomLink is the LoRa point-to-point link layer. It operates on link frames: a small
fixed binary header plus an opaque serialized Envelope payload (the BoomProtocol
message — see BoomProtocol). BoomLink never looks inside that
payload and has no Protobuf/Nanopb dependency — it filters, acknowledges and
deduplicates packets by inspecting only its own header bytes.
Source: fw/common/boomlink/linkframe/ (the frame header codec) and
fw/common/boomlink/linkengine/ (addressing, ACK, retry, duplicate suppression, the TX
queue) — both target-agnostic C, host-testable against a fake radio. The STM32-specific
glue (App/link/link_service.c, wiring a real radio in) is a thin call site on top.
Link frame¶
Every LoRa packet is one BoomLink frame:
+--------------------+----------------------------------+
| link frame header | serialized Protobuf Envelope |
| (fixed binary, | (Nanopb; DATA frames only) |
| 20 bytes) | |
+--------------------+----------------------------------+
The header is deliberately not Protobuf — BoomLink must be able to reject foreign traffic and match ACKs by inspecting a few leading bytes, without invoking a decoder. It is the only hand-packed binary structure on the air interface.
| Offset | Size | Field | Notes |
|---|---|---|---|
| 0 | 1 | magic / network ID | runtime-configurable, default 0xB0 |
| 1 | 1 | version (high nibble) | frame type (low nibble) | |
| 2 | 1 | flags | bit 0: ack_requested, bit 1: more_fragments, bits 2–7 reserved (must be 0, ignored if set) |
| 3 | 1 | fragment_index | always 0 — no fragmentation implemented yet |
| 4 | 4 | destination_id | little-endian |
| 8 | 4 | source_id | little-endian |
| 12 | 4 | session_id | little-endian |
| 16 | 4 | sequence | little-endian |
Frame types:
| Type | Value | Payload |
|---|---|---|
DATA |
1 | header + serialized Envelope |
ACK |
2 | header only, no payload |
A packet whose magic/network ID or protocol version doesn't match is dropped and counted before any further processing. LoRa's own PHY CRC is relied on; the link frame adds none of its own.
Addressing¶
A node accepts a frame when destination_id == its own node_id or
destination_id == 0xFFFFFFFF — but only once it has a valid node_id itself: a node
whose own address isn't in 0x00000001..0xFFFFFFFE accepts nothing at all, broadcast
included. This closes two failure modes: a factory-fresh node (address 0) matching
traffic meant for "unconfigured", and a node misconfigured to the broadcast address
answering on behalf of the whole network.
There is no special "master" address — see LoRa → Naming.
No sender authentication — trusted-network design, for now
source_id is whatever the sender put in its own frame header — nothing checks it
against the radio identity that actually transmitted the packet. A frame with valid
magic/version and a forged source_id is dispatched exactly like a genuine one,
which means state-changing traffic (a Command, a ConfigSet) can be spoofed by
anything sharing the network's magic byte and radio parameters. This is a
deliberate, documented, current limitation — not an oversight left in by accident —
tracked for a future authentication baseline in issue
#77. Until that
lands, treat every node on a given magic as mutually trusted.
TX pipeline¶
flowchart TB
A[Application message] --> B[BoomProtocol builds Envelope]
B --> C[Nanopb encode]
C --> D["TX queue<br/>(destination rules + priority)"]
D --> E[dequeue for transmission]
E --> F["assign session/sequence<br/>(at dequeue, so priority reordering<br/>never breaks the on-air sequence)"]
F --> G[build link frame header]
G --> H[track ACK state if required]
H --> I[Radio send]
A retransmission reuses the already-assigned (session_id, sequence) — only the first
transmission of a frame assigns a new one.
RX pipeline¶
flowchart TB
A[Radio packet] --> B["validate magic/version<br/>+ frame length"]
B --> C[match ACK frames against pending TX]
C --> D[validate destination]
D --> E[duplicate check]
E --> F[generate ACK if required]
F --> G[update link statistics]
G --> H[Nanopb decode the payload]
H --> I[protocol dispatcher → target service]
Malformed packets are dropped and counted — a bad link header at this layer, a failed Protobuf decode one layer up — and neither class ever reaches an application handler.
Sequence and session¶
Each node keeps a session_id generated once per boot and a sequence counter
incremented per transmitted envelope. A packet's link-level identity is
(source_id, session_id, sequence). A new session_id after a reboot is what lets a
peer's duplicate cache re-key instead of treating a restarted sequence counter as a
wave of duplicates.
That new session_id isn't reliably new
session_id is derived from this chip's fixed UID XORed with the boot-time tick
count. On a healthy board, every step from reset to this derivation is either
straight-line code or a fixed-length delay — no variable-length wait — so the tick
term reads the same value on every single reboot, making an identical
session_id the common case, not a rare one. A peer's duplicate cache then sees a
session it already knows, with a sequence counter that just restarted from 0, and
silently suppresses the rebooted node's early post-boot frames as replays while
still ACKing them — so an ACKed send doesn't actually mean the payload reached
the peer's application. A real, known gap, not yet fixed — tracked in issue
#91.
Duplicate suppression¶
Retransmission can deliver the same valid packet more than once; an application handler must see it at most once.
- a statically-allocated table of the 16 most recently active
(source_id, session_id)pairs (ample for a 5–10 node network); - each entry tracks the highest accepted sequence plus a small bitmap window below it (tolerates minor reordering);
- LRU eviction when full;
- a new
session_idfrom a source already in the table reuses that entry (the peer rebooted) rather than adding a second one.
A duplicate is never re-dispatched to the application, but if it was unicast and requested ACK, the ACK is sent again — a broadcast frame is never ACKed at all, matching the ACK rules below.
ACK¶
ACK is a link-layer delivery acknowledgement, not an application response — a
command can produce both an ACK ("the packet arrived") and a CommandResponse ("the
command ran, and here's the result"). ACK is a link frame type, not a Protobuf
message, and carries no payload:
frame type = ACK
destination_id = original source_id
source_id = acknowledging node
session_id = original packet's session_id
sequence = original packet's sequence
ack_requested = 0
Rules:
- ACK frames never request another ACK;
- broadcast frames never request (or receive) an ACK, whatever their flags say;
- a duplicate of an ACK-requested unicast frame causes the ACK to be resent;
- ACK confirms link delivery only — never semantic success of the payload. Application
request/response correlation uses
MessageHeader.request_id, not the link sequence.
Retry¶
BoomLink v1 is stop-and-wait: at most one ACK-pending frame is outstanding at any time, globally, across the whole node — the radio is half-duplex, so transmitting a second frame while waiting for an ACK would mean missing it.
flowchart LR
TX[TX] --> W[wait for matching ACK]
W -->|ACK received| OK[success]
W -->|timeout| B[randomized backoff]
B --> TX
- default 3 total transmission attempts;
- randomized backoff on each retry (separate range from the first-transmission jitter below);
- a retransmission reuses the same
(session_id, sequence), so the receiver's duplicate cache still recognizes it; - final failure is surfaced to the caller and counted — BoomLink does not retry forever.
The outcome reported to a caller distinguishes three cases: acked (delivery confirmed), sent (no ACK was requested, so nothing more is known), and no ack (every attempt transmitted, none acknowledged — the frame may still have arrived; only the ACK was lost).
Random jitter (collision avoidance)¶
Several nodes can detect the same event (a gunshot, say) at almost the same instant. If
every node transmits immediately, collision probability is high. BoomLink draws a
random delay, uniform over [0, tx_jitter_max_ms], applied once before a frame's
first transmission (a retry uses the retry backoff range instead). This applies to
every queued frame, not only detections — the link layer never looks inside the
payload, so it can't tell one message type from another. Setting the max to 0
disables the delay.
The original event timestamp is captured before this delay, so localization timing is never affected by radio scheduling.
Priority TX queue¶
| Priority | Traffic |
|---|---|
| HIGH | command responses, critical system messages |
| NORMAL | detection events, configuration responses, Ping/Pong, fleet-discovery Wakeup |
| LOW | periodic telemetry, non-critical diagnostics |
ACK never enters this queue
Despite being nominally a HIGH-priority frame type, an ACK is sent synchronously from the RX path the instant it's generated — it never touches the TX queue at all. There's nothing to prioritize: at most one frame is ever mid-flight (see Retry), so an ACK can't be queued behind anything.
The queue is statically bounded; when full, the lowest-priority queued frame is dropped to make room. Sequence numbers are assigned at dequeue, so priority reordering never breaks the on-air sequence.
LOW exists in code (BOOMLINK_TXPRIO_LOW) but nothing sends at that priority
today — no telemetry subsystem exists yet to generate the traffic it's meant for (see
BoomProtocol → Telemetry).
Reordering, not preemption
This only reorders the queue. Once a frame leaves the queue into the single global retry pipeline (see Retry above), nothing can displace it — a low-priority frame already mid-retry holds that slot for the rest of its retry/backoff cycle even if a high-priority frame is queued behind it. That's bounded by the attempt count and backoff range (low digits of seconds at this network's traffic rate), not indefinite.
Broadcast¶
Destination 0xFFFFFFFF. Broadcast frames never request (or receive) a link ACK.
Commands or config writes that would be dangerous if broadcast (Reboot, a
ConfigSet) are rejected by the application service unless explicitly designed for it
— see BoomProtocol.
Statistics¶
Exposed via link status (see CLI reference below) and
boomlink_link_get_stats():
TX envelopes · RX envelopes · TX retries · TX failures · RX duplicates · malformed packets · packets for another destination · ACK sent/received · unmatched ACK · invalid-source frames · oversize packets · rejected by magic/version · dropped before queueing · shed for more urgent traffic · cumulative TX airtime · last RSSI/SNR.
Current defaults¶
| Parameter | Value |
|---|---|
| Network magic | 0xB0 |
| ACK timeout margin | 50 ms |
| Max transmission attempts | 3 |
| Retry backoff range | 100–400 ms |
| First-TX jitter range | 0–50 ms |
ConfigSet accepts these fields today, but most have no real effect yet
LinkConfig (including all five defaults above) can be written via ConfigSet —
it's validated, versioned, and persisted to flash — but nothing in the firmware
currently reads any of these five fields back to reconfigure the running link
engine: link_service_init() hardcodes them from its own constants at boot,
regardless of what's in flash, and nothing calls the link engine's reconfigure
entry point outside of host tests. Writing a new backoff range or jitter max today
has no observable effect, live or after a reboot.
node_id and magic are different: a write to either does eventually take
effect — but only from link_service_init()'s next boot-time read, since there's
no live address/network-ID change either. See
BoomProtocol → Configuration for the full picture,
including the radio profile (same "accepted but not applied" gap).
CLI reference¶
| Command | Effect |
|---|---|
link status |
node ID, session ID, TX state, queue depth, most statistics (cumulative TX airtime isn't printed today) |
link enable / link disable |
link engine owns the radio's RX path / hands it back to raw radio ping |
link ping <node_id_hex> [text] |
send a unicast frame with ACK requested — the ACK itself is the "pong" |