The USB control envelope
The LNode's transport CDC carries HDLC-framed Reticulum packets, plus a
small out-of-band control plane between an attached host (lnflash,
lnsd) and the firmware. Until Codeberg #238 that control plane was one
hand-cut magic per feature — a radio-config frame and a reset frame, each
recognised by shape. Three pending features each wanted a third magic,
which is how a channel becomes unextendable. This page documents the one
envelope every control frame rides in now, and how the two legacy magics
retire.
Wire truth lives in leviculum-core/src/envelope.rs; this page explains
it. If they disagree, the code and its tests win.
Frame layout
One envelope per HDLC frame:
[0xA4, 0xA5] [type: u8] [len: u16 BE] [payload: len bytes]
The length is strict: a frame whose payload is shorter or longer than
len is malformed. A reader that knows the envelope but not the type
answers a named refusal and stays in sync — the HDLC delimiter bounds the
frame, the header names what was skipped. Nothing envelope-shaped is ever
answered with silence; the legacy magics predate that rule and keep their
old manners (below).
Frame types
Commands (host → board):
| type | name | payload |
|---|---|---|
| 0x01 | RADIO_CONFIG | the legacy frame's parameter block (13–19 B), no magic |
| 0x02 | RESET | empty |
| 0x03 | WALL_TIME | unix seconds, u64 BE (8 B) |
| 0x04 | CAPABILITIES | empty (a query) |
| 0x05 | TELEMETRY_TARGET | see below — set or clear the telemetry target |
| 0x06 | TX_SPACING | on-air transmit spacing in ms, u16 BE (2 B) |
| 0x07 | RADIO_QUERY | empty (a query, #349) — answered with RADIO_REPORT |
| 0x08 | FIXED_POSITION | see below — set or clear the user-set position |
| 0x09 | MEDIA_PROFILE | one flag byte (bit0 lora, bit1 ble) — answered with MEDIA_REPORT |
| 0x0A | MEDIA_QUERY | empty (a query) — answered with MEDIA_REPORT |
| 0x0B | POSITION_SOURCE_QUERY | empty (a query) — answered with POSITION_SOURCE_REPORT |
| 0x0C | NODE_NAME | see below — set or clear the operator-chosen name |
| 0x0D | NODE_NAME_QUERY | empty (a query) — answered with NODE_NAME_REPORT |
| 0x0E | IDENTITY_QUERY | empty (a query) — answered with IDENTITY_REPORT |
| 0x0F | ANNOUNCE | empty — announce now: the LXMF delivery destination, and the propagation destination where that role runs (#376, #384) |
| 0x10 | BLE_TX_GAP | BLE inter-packet gap in ms, u16 BE (2 B), 0..=5000 (#376) |
| 0x11 | STORE_STORM | record count and body size, two u16 BE (4 B), 1..=1000 and 0..=1024 (#384) |
| 0x12 | PN_CONFIG | announced stamp cost and required peering cost, one byte each (2 B); 0xFF in a field keeps the persisted value (#384) |
| 0x13 | MGMT_ALLOW | see below — set or clear the remote-management allow-list (#235) |
| 0x14 | MGMT_ALLOW_QUERY | empty (a query) — answered with MGMT_ALLOW_REPORT |
Responses (board → host):
| type | name | payload |
|---|---|---|
| 0x81 | ACK | [acked_type] |
| 0x82 | REFUSAL | [refused_type, reason] |
| 0x83 | CAPABILITY_REPORT | [version, accepted types...] |
| 0x84 | RADIO_REPORT | the RADIO_CONFIG parameter block the radio is running (#349) |
| 0x85 | MEDIA_REPORT | [running_flags, configured_flags] in the MEDIA_PROFILE flag encoding |
| 0x86 | POSITION_SOURCE_REPORT | one flag byte (bit0 fixed position set, bit1 GNSS built in and active) |
| 0x87 | NODE_NAME_REPORT | [flags, mesh_len, mesh…, ble_len, ble…] — see below |
| 0x88 | IDENTITY_REPORT | [flags, identity(16), probe(16), lxmf(16)], 49 B fixed |
| 0x89 | MGMT_ALLOW_REPORT | [flags, count, count × identity(16)] — see below |
| 0x8A | DUTY_HOLD | [state, lt_ms: u32 BE], 5 B; state 0x00 lifted, 0x01 held. Unsolicited on each hold edge, and behind every MEDIA_REPORT — see below |
Refusal reasons: 0x01 unknown type, 0x02 malformed, 0x03 value
refused, 0x04 busy, 0x05 unsupported (the envelope layer knows the
type but this binary carries no consumer for it — retrying or rebooting
cannot help, only different firmware can), 0x06 not persisted (see
below), 0x07 no calendar clock (the command needs one and the board has
none yet — seed it with a GNSS fix or --set-time and retry), 0x08 not
running (the value is durably on the flash page and applies at the next
reset, but the carrier it configures did not come up this boot — see
below). The version in the capability report (1) names the envelope framing itself;
new frame types extend the accepted list without bumping it.
DUTY_HOLD (0x8A) — the board says when its LoRa queue is held
[state: u8] [lt_ms: u32 BE] state 0x00 lifted, 0x01 held
The one board → host frame that is not only an answer (leviculum#501).
When the regulatory airtime budget runs out, the board's LoRa TX gate
holds its queue (leviculum#493), and a board driven by lnsd over this
port has to say so, or the daemon in front of it keeps relaying announces
into a queue that cannot send them, advertising routes through a relay
that cannot carry the link setups they invite. The firmware sends the
frame unsolicited on each edge of the hold, and once more right behind
every answer to MEDIA_QUERY, so a host that attached mid-hold learns the
state without waiting for the next edge. lt_ms is the keyed airtime of
the rolling hour as the board's ledger last stood.
A second frame after the MEDIA_REPORT rather than a longer report: every host already in the field decodes the report strictly at two bytes, and skips a frame type it does not know. A state byte other than the two is refused by the decoder, never read as lifted; a payload longer than five bytes is read by its first five.
lnsd asks with one MEDIA_QUERY each time its serial interface attaches,
after the radio bring-up, mirrors the state into the interface's
duty_hold flag, logs DUTY_HOLD iface=… state=held|lifted lt_ms=… on
each change (the line its RNode path emits), and clears the flag with
cause=iface_down when the port goes away. This is the USB protocol
between our board and our host, not the radio: nothing of it goes on
the air, and no Python-RNS peer ever sees it.
MGMT_ALLOW (0x13) and MGMT_ALLOW_QUERY (0x14) — who may read the board
A board with a remote-management allow-list serves
rnstransport.remote.management with the /status handler, so rnstatus -R <board> and lnstatus -R <board> read it exactly as they read a
daemon (#235, #86). The list is identity hashes — the querying instance's
own identity, the one it signs the link with, not a destination.
MGMT_ALLOW's payload is [count, count × 16 B], at most eight
identities (leviculum_core::mgmt_allow_store::MGMT_ALLOW_MAX_IDENTITIES,
which argues the bound against the flash slot and the #388 heap budget).
count == 0 is the explicit clear. A count above the bound is refused
0x03 (value) — never truncated, because a permission set that arrives
different from the one that was sent is one nobody authorised. A payload
whose length disagrees with its own count is refused 0x02 (malformed).
Both frames are answered with MGMT_ALLOW_REPORT, not an ack, because
two states differ and only the board knows both: bit0 of its flags says a
record is on the page, bit1 says this boot registered the management
destination. The destination is created while the node is built, from the
record read at boot, so a list set now is served after the next reset —
and an identity revoked now is still being served until then. The report
also echoes what the board stored (duplicates dropped), so a host prints
the list the board acknowledged rather than a repeat of its own argv. A
record that did not reach flash is refused 0x06 (not persisted): the
whole point of a set is the next boot.
This frame is the only way the list can be written, and it is
USB-only. That is a property of where the parser sits, not of a check
inside it: classify_control_frame has exactly one caller in the
firmware, the transport CDC read path (leviculum-nrf/src/usb.rs), and
bytes arriving from the LoRa or BLE interface go to the node core as
Reticulum packets, where the same frame is dropped — its first byte
0xA4 has the IFAC bit set and no radio carrier on a board runs IFAC.
Driven on a real NodeCore in
leviculum-core/src/node/mvr_mgmt_allow_is_usb_only.rs.
An absent or empty list registers nothing at all on a board: no
destination, no handler, no announce. That is a deliberate deviation from
the daemon, which registers the handler and consults an empty list per
request the way Python does (leviculum-std/src/config.rs). A daemon
sits on a machine with an operator and a login; a board is left on a mast,
and an unattended node announcing a management destination with nobody on
the list is advertising a door.
Host side: lnflash --management-identity <hex> (repeatable) and
lnflash --clear-management, plus the flash-time question beside the
radio one.
ANNOUNCE (0x0F) and BLE_TX_GAP (0x10) — the #376 bench instruments
ANNOUNCE makes the board make every announce it makes on its own
cadence, immediately and on all interfaces. That is TWO announces on a
board running the propagation role, and one on a board without it:
- the LXMF delivery destination, exactly as the telemetry path
announces it before a report — same destination, same app data, and the
same clock gate: without a calendar clock the board withholds it, logs
[ANNOUNCE] withheld reason=no-clockon the debug port and, if it has nothing else to announce, refuses with reason0x07. (The gate is not cosmetic: the emission timestamp inside the announce is what peers rank paths by — seedocs/src/protocol-notes/announce-dedup-and-path-replacement.md— so an uptime-stamped announce would poison the path under measurement.) On success:[ANNOUNCE] sent dst=<hex8> reason=host. - the lxmf.propagation destination, where the role runs (#384),
exactly as the role announces it on its 300 s interval — and NOT
clock-gated, because a clockless board still announces the role with its
uptime timebase (#384 item 6) and the contact that invites is what
delivers a clock seed. On success:
[ANNOUNCE] sent dst=<hex8> reason=pn-host. Withheld only when the record store did not mount (PN announce withheld reason=store-unmounted): a role that cannot prove an upload must not invite one.
Both, and not just the first, because Reticulum's identity cache is keyed
by destination hash: a client that heard the delivery announce still
cannot address the board's mailbox, so set_outbound_propagation_node
fails with identity for <hash> not known until the role's announce
arrives too.
The ack therefore grades the COMMAND: OK once at least one announce left
the board, reason 0x07 (no clock) when the only announce this board has
was withheld by the clock gate, and reason 0x03 (unsupported) when it
has none to make. Which ones went out is on the [ANNOUNCE] sent ... reason= lines, beside the usual BLE_TX_PKT lines. One-shot; nothing is
persisted. Host side: lnflash --announce, and periculum's
announce_board step, which relays the same frame through the board's
owning daemon (the daemon holds the data port TIOCEXCL).
BLE_TX_GAP sets the gap the BLE drain leaves between the last fragment
of one packet and the first fragment of the next packet on the same
connection handle. With no value set the pumps serve the compiled
default of 100 ms (#376, the measured desk value —
leviculum-ble-tx's DEFAULT_TX_GAP_MS); any set value overrides it,
0 disables the gap entirely, and values above 5000 ms are refused
with reason 0x03. Interface-layer only, per connection — the fan-out
and the core never learn of it — and volatile like TX_SPACING: a reset
restores the default. The board logs [BLE ] tx_gap_ms=<n> when the
value takes effect and BLE_TX_GAP conn=<h> waited_ms=<n> once per
deferred packet. Host side: lnflash --set-ble-tx-gap <ms>.
STORE_STORM (0x11) — the #384 bench instrument
Appends records synthetic records of size body bytes each to the
board's message store (leviculum-nrf/src/record_store.rs, the record log
on the 64 KiB region memory.x reserves behind the image). Nothing else
writes to that store yet: there is no LXMF propagation node, and this frame
exists so the one cost the store imposes on the rest of the board can be
measured before anything depends on it. That cost is erases — a 4 KiB page
erase holds the flash for ~85 ms (nRF52840 PS, NVMC) and the SoftDevice
has to fit it between radio events — so the question "what does a filling
store do to BLE throughput and LoRa airtime" needs a way to provoke the
erases without waiting for a mesh to fill 16 pages.
Bounds are in classify_control_frame, so every binary refuses the same
values: records must be 1..=1000 and size 0..=1024, and anything else
is refused with reason 0x03. A board whose store did not mount, or which
is still running the previous storm, refuses with 0x04 (busy) — those two
conditions are the firmware's to see, not the classifier's.
The ack means the request was accepted, not that the records are on the page: the store task appends them on its own time, which is the point (the measurement runs while it writes). What reports the result is the board's debug port:
STORE mount state=<ours|formatted> pages=<n> live=<n> free_bytes=<n> t=<ms>
STORE storm records=<n> size=<n> appended=<n> failed=<n> seq=<n> ms=<n>
STORE op_fail op=<erase|write> attempt=<n> t=<ms>
STORE stats appends=<n> fails=<n> sealed_pages=<n> t=<ms>
Nothing is persisted as configuration, and the records carry a synthetic
tag so a later purge can find them. Host side:
lnflash --store-storm <count>[,<bytes>].
NODE_NAME (0x0C) and NODE_NAME_REPORT (0x87)
The name an operator chooses for a board, replacing both derived
defaults at once — the LXMF announce's display name (LNode-<hex8>, what
Columba lists) and the BLE device name (LN-<hex8>, what a phone shows in
its Bluetooth settings). A board answering to two different names in two
places would be worse than the hex it replaced. The name is display only:
it never touches the identity, so two boards may carry the same name and
stay distinguishable everywhere it matters.
Set payload, the FIXED_POSITION set/clear shape on a variable-length value:
[set: u8] ([name: 1..=32 bytes of UTF-8])
set is 0x00 (clear, back to the derived defaults; 1-byte payload) or
0x01. No length byte — the envelope header already carries the frame
length. The 32-byte bound is airtime policy, not a wire limit: the
name rides in every announce, so leviculum_core::node_name derives it
from the announce's on-air cost and leviculum-lxmf/tests/ announce_name_airtime.rs pins every number in that derivation. Invalid
UTF-8, control characters, surrounding whitespace and an over-long name
are all refused as malformed rather than silently shortened: a name that
arrives different from the one that was typed is worse than an error.
The report answers both frames:
[flags: u8] [mesh_len: u8] [mesh…] [ble_len: u8] [ble…]
flags bit0 is "a name is stored" (as opposed to both names being
derived) and bit1 is "the BLE surfaces are one reset behind". Unknown bits
are kept, not refused.
The two names are the effective ones, not the stored record, because a
host cannot derive either: the two defaults are different strings built
from an identity hash the host never sees, and the BLE name is
additionally shortened to leviculum_ble_tx::DEVICE_NAME_LEN (11 bytes)
on a codepoint boundary. They also adopt the name at different moments —
the mesh name is in force for the next announce, while the advertisement
was built once at boot and cannot be rebuilt under a live SoftDevice — and
bit1 is the board saying so. That is the MEDIA_REPORT
running-versus-configured argument on a second feature.
A board that has not yet published its identity hash (USB comes up several
statements into the firmware's main, the node only after the LoRa
bring-up's awaited SPI transactions) answers busy and applies nothing,
so the host's retry is a real retry. unsupported is reserved for a
binary that carries no name gate at all.
What an answer on the persist path means (#358)
Four frames write a flash record: TELEMETRY_TARGET (0x05), FIXED_POSITION (0x08), MEDIA_PROFILE (0x09) and NODE_NAME (0x0C). For those four the answer carries a durability promise:
When the client's call returns, a reset cannot lose the setting.
The board therefore does not answer them until its store task confirms
the record is on the page. An ACK — or, for the media profile and the
node name, their report — means written, not merely applied. A write the store task
gave up on comes back as a refusal with reason 0x06: the board is
running the value, and cannot promise it survives a reboot. That is a
different sentence from busy (retry) and from value refused (the
value was fine), so a client can tell it apart and say so.
The wait is bounded at 2.5 s, inside the 3.5 s window lnflash gives one
control conversation; a store task that never confirms is reported as
0x06 rather than left holding the port. Until #358 the answer went out
between the RAM apply and the page write, so a scripted set followed by
a reset — periculum's per-scenario media application, lnflash, any
automation — could reboot the board inside the window and lose the
setting. A sleep in front of the reset does not close it: the store task
may be working an earlier queued write, and a constant cannot bound a
queue.
What the RADIO_CONFIG answer means
The same promise-shape on the radio path: a RADIO_CONFIG ack means the
radio is running this configuration — the serial task waits until the
LoRa task confirms the apply and the running config matches what was
delivered, bounded at 1.2 s on top of the 500 ms delivery grace, inside
the tightest host window (lnsd's legacy sender waits 2 s per attempt).
A config delivered but not yet confirmed — a retune deferring to a frame
mid-air on a slow profile, or a reconfig that failed on the SPI bus —
answers busy, and a retry after the apply lands is acked immediately
because the running config already matches. Until this wait existed the
ack went out on channel delivery, measurably 1.1–18.9 s before the
apply while the LoRa loop parked in single-mode RX, and even when the
reconfig then failed.
That retry is answered for the config that is queued, not for the
channel. The config channel holds one slot, and a host re-sending the
config it was just told was busy finds that slot still holding its own
first copy. That is not a delivery that failed: the board answers busy
again and acks as soon as the apply lands, rather than spending the
host's attempt on undeliverable. Only a slot held by a different
config is refused, and only after the 500 ms grace. Before 2026-09-23 a
repeat was refused: lora_path_discovery_wide_mixed had its config
delivered on the first attempt (from a site=yield RX window the loop
did not wake from, so the apply missed the 1.2 s wait), then attempts two
and three were refused as undeliverable and the cell was skipped
no_ack_after_3. Every RX window the LoRa loop can park in now wakes on
a queued config, which is the other half of the same fix.
One boot state changes the promise: a board whose boot did not bring the
LoRa carrier up (a lora=off media profile in flash) has no LoRa task,
so no config can be delivered or applied before the next reset. The
config goes to the flash store, and the answer waits for the confirmed
page write (#358) — a failed write refuses with 0x06 (persist), a
confirmed one refuses with 0x08 (not running). A refusal rather
than an ack because the only true claim here is a reboot comes back on
this configuration, which is not the claim an ack makes: until #363 both
states sent the same three bytes, and a host that reads that ack as "the
board is on this PHY" prices every frame at a modulation nothing is
keying. 0x08 is the mirror of 0x06: persist is
applied-but-not-durable, not-running is durable-but-not-applied, and
neither is a rejection — the value was taken both times.
The legacy magic frame keeps its ACK in this state, because ack-or-silence
is its whole vocabulary: there is no room in three bytes for a carrier
flag, and silence reads as "the frame never landed" to a sender whose next
act is the reset that applies the page. That is the contract the test
harness relies on when it pushes the scenario channel one reboot early and
resets afterwards. A legacy host that needs to know whether the radio is
running the configuration asks RADIO_QUERY, which a board with no LoRa
task refuses as busy rather than answering out of the flash page. Both
dialects decide this in one place
(leviculum_core::envelope::radio_config_answer and
legacy_radio_config_acked) so the pair cannot drift.
Before 2026-09-22 such a boot fed the config to the taskless channel
instead: the first one wedged its single slot for the rest of the boot,
every later one was refused as busy, and one BLE-profiled boot cost a
corpus run all 26 of its LNode cells
(SKIPPED_INFRA reason=lnode_radio_config_failed result=no_ack_after_3).
The media-profile frames
[flags: u8] bit0 = lora, bit1 = ble; set means the carrier is enabled
Both media frames are answered with a MEDIA_REPORT rather than an ACK,
because the two profiles it carries can honestly differ. running is
what the board is carrying traffic on right now; configured is what a
reset would come up with. They part exactly when a carrier that did not
come up at boot is switched on: the board has no driver task to start,
and an ack would claim it did. A flag byte with a bit outside the two
known carriers is malformed, never masked down to "that carrier is
off" — the firmware does not get to invent a reading of a carrier it
does not know.
The default, for a board with no stored profile, is both carriers on:
absence of a record must change nothing about a fielded board. Concept
and semantics: docs/src/concepts/media-profiles.md.
The wall-time frame calls the calendar seam
(set_wall_time_unix_secs(.., TimeSource::Host)); the seam's sanity
window decides between the ack and a value refused refusal, and an
accepted seed logs [TIME_SEED] source=host and flips the banner's
[TIME_SOURCE] to host — the exact mirror of the GNSS path.
The transmit-spacing frame (#345)
[spacing_ms: u16 BE]
The gap the board's LoRa interface leaves between the end of one packet's
airtime and the key-up of the next. It is applied inside
transmit_all_frames, the last thing before the radio is keyed, so it is a
gap between two packets on the air rather than between two hand-overs, and
whatever the transmit path already spent since the previous packet ended
(the CAD, the SPI traffic, the log lines) is counted against the requested
gap rather than added to it. The split frames of one packet are unaffected:
they still go out back-to-back, because the receiver's reassembler requires
that.
Every u16 value is legal, 0 included — 0 is the compiled default and
imposes nothing, so the only malformed frame is one of the wrong length.
The value is not persisted: it is a measurement instrument (the sweep of
the telemetry announce/report spacing, #345), and a reset returns the board
to the default. The board logs [LORA_TX_SPACING] intended_ms=… waited_ms=… gap_ms=… at every key-up; gap_ms is the gap that was measured, and -1
is the first packet since boot, which has no previous airtime edge to be
measured from.
lnflash --set-tx-spacing <MS> is the host side.
The telemetry-target frame (#236)
[profile: u8] [dest_hash: 16] [key_present: u8] ([public_key: 64])
key_present is 0x00 or 0x01, never inferred from the length: per
the #236 UX decisions (2026-08-22) the public key is optional and
hash-only is the common case — the user knows the LXMF address, the node
resolves the key over the air.
Profile ids:
| id | name | meaning |
|---|---|---|
| 0x00 | OFF | clear the target — telemetry off |
| 0x01 | TRACKER | movement-driven cadence |
| 0x02 | STATION | slow heartbeat only; the default profile |
0x00 is the clear encoding. It rides in the profile slot rather than
in a magic destination hash because that slot's whole job is to say
which cadence applies, and "none" belongs in its vocabulary; the rest of
the payload is still parsed and must still be well formed, so a clear
frame is not a licence to send a short one. The destination hash and key
of a clear frame are ignored, and encode_telemetry_clear zeroes them
rather than echoing a target back for no reason.
An id the firmware does not know is not a refusal: the destination
is kept and the default profile's cadence runs, because a newer host's
cadence preference is not worth losing a configured target over. Which
profile is actually running is in the board's [TELEMETRY] banner.
Firmware from before #236 answers this type with an unknown type
refusal and leaves it out of its capability report, which is precisely
how a #236-aware host detects a pre-#236 board.
How lnflash drives it
Telemetry is configuration, not firmware, so the same frame is reachable from the flash flow and without flashing anything:
| flag | effect |
|---|---|
| (none) | after the radio step: Send telemetry? [y/N], default no |
--telemetry <ADDRESS> | implies yes; 32 hex chars, spaces/colons/case tolerated |
--telemetry-profile <tracker|station> | which cadence; default station |
--telemetry-key <128 hex> | the key-present form; absent = hash-only, the common case |
--no-telemetry | send profile 0x00 — clear whatever the board had stored |
--set-telemetry | the same configuration on running boards, no flash |
Answering no at the prompt sends nothing; --no-telemetry sends a
clear frame. The difference matters on a board that already has a target:
silence leaves it, the clear frame removes it.
A yes needs exactly one input — the LXMF address — because that is what
users have. Nothing detects a terminal: Ui::ask answers "no answer" for
--yes and for a piped or closed stdin alike, and every prompt treats
that as its stated default, so a scripted run cannot block.
What the host reports back is the ack. The node's own
[TELEMETRY] target=… state=off|no-position-source|awaiting-key|ready
line goes to the debug CDC (if00), which lnflash holds open only for
the post-flash boot check — so it is named as the place to read the rest
rather than read back over a second connection.
The consequence sentence. A target alone does not make a board
report: sending the position is the switch for sending everything
(docs/src/concepts/telemetry.md), so a board with neither a fixed
position nor a GNSS receiver stores the target and stays silent. After
an ack, --set-telemetry therefore asks the board itself
(POSITION_SOURCE_QUERY, on the same open port) and, when the answer is
"neither", says so:
3-2.4: target stored; nothing will be sent until a position source
exists — set one with --set-position.
Honest, not a refusal: the target is valid configuration and it is stored. A board that answers with a source is told nothing of the kind, and a board that does not answer the query at all — firmware without it, or a binary with no reporter, which refuses it by name — is told nothing either. Guessing here would put a false warning in front of an operator whose board is fine.
The fixed-position frame
[set: u8] ([latitude_e6: i32 BE] [longitude_e6: i32 BE]
[alt_present: u8] ([altitude_e2: i32 BE]))
A user-set position as the telemetry source. set is 0x00 (clear, the
1-byte payload is the whole command) or 0x01; alt_present follows the
telemetry target's key-present rule — an explicit flag byte, never
inferred from the length. Units are the telemetry wire's own scaled
integers: degrees × 1e6, metres × 1e2, so the coordinates the user typed
are the coordinates that go on the air. A latitude beyond ±90° or a
longitude beyond ±180° is refused as malformed.
Semantics (decided 2026-08-30): while set, the fixed position replaces
the position sensor entirely, in every profile — no blending, no
fallback surprises — and the explicit clear returns the node to sensor
reporting, which for a GNSS-less binary means no position. The board
persists it beside the telemetry target (same flash page, so it survives
resets and UF2 updates), marks the source in its report line as
possrc=fixed|gnss, and puts it on the wire in Sideband's own
fixed-location shape: accuracy 0.01 m, speed and bearing 0, altitude 0
when unset (Location.update_data, synthesized branch, Sideband
2000d81).
The ack is capability-gated exactly like the telemetry target's: only
the reporter reads the position, so a binary without one answers the
unsupported refusal rather than acking a pin nothing will ever report.
How lnflash drives it
| flag | effect |
|---|---|
--set-position LAT,LON[,ALT] | set it on every running board, then exit; no flash |
--clear-position | back to sensor reporting |
The value is decimal degrees, comma or space separated, sign or
hemisphere letter (52.52,13.405,34, "52.52N 13.405E", 36.85S,73.04W
all parse; a letter and a sign together do not). The optional third value
is the altitude in metres. Degrees/minutes/seconds notation is refused by
name rather than misparsed.
Why an envelope frame can never be a packet
The channel's other occupant is HDLC-framed Reticulum traffic, so every control frame must be unmistakable. Three facts hold it:
- The first magic byte
0xA4has the IFAC bit set, and this channel runs without IFAC — no peer on it emits a packet whose first byte matches, and firmware from before the envelope drops a received envelope frame in packet parsing for the same reason. - Every frame a host may send before it knows the peer speaks the envelope — the capability probe, wall time, reset — is shorter than the 19-byte minimum Reticulum wire packet, so it cannot be packet-shaped at all.
- Frames at that size or beyond (radio config at 24 B, telemetry target at up to 87 B, a set fixed position at exactly 19 B) are only sent after a capability report proved the peer is envelope-speaking firmware. This ordering is load-bearing: an envelope speaker must probe before it sends any envelope frame of 19 bytes or more.
Compatibility window, and how it retires
The two legacy magics stay accepted, with their legacy answers, so both field directions keep working:
- Old host tool → new firmware: the legacy 21-byte config magic and
the 4-byte reset magic are classified ahead of the envelope
(
classify_control_frame) and answered with the legacy two-byte-style acks (RADIO_CONFIG_ACK,RADIO_RESET_ACK). An invalid legacy config keeps its historical silence; audible refusals begin with the envelope. - New host tool → old firmware:
lnflashopens every control conversation with a capability probe. Firmware that answers gets envelope frames; firmware that stays silent (pre-envelope) gets the legacy config magic as a fallback, and--set-timereports "this firmware predates the control envelope" by name instead of guessing.
lnsd still speaks the legacy config magic on every connect; it migrates
to the envelope in its own batch.
Retirement happens in that order: first lnsd and every shipped host
tool speak the envelope (probing, with fallback), then — after a release
cycle in which lnflash bundles only envelope-speaking firmware, so any
field board a current tool meets accepts it — the firmware drops the two
legacy classifier arms and the host tools drop the fallback. Each step is
observable: a host that still needs the fallback logs it, and a board
that still receives legacy magics is running firmware older than the
bundle that introduced the envelope.
Adding a fourth frame type
The definition of done for #238: allocate the next type constant in
leviculum-core/src/envelope.rs, give it a payload codec with tests, add
a ControlAction variant and its executor arm in
leviculum-nrf/src/usb.rs, and append the type to
ACCEPTED_CONTROL_TYPES so the capability report advertises it. The
framing, the refusal path, the probe, and both host speakers stay
untouched.