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

typenamepayload
0x01RADIO_CONFIGthe legacy frame's parameter block (13–19 B), no magic
0x02RESETempty
0x03WALL_TIMEunix seconds, u64 BE (8 B)
0x04CAPABILITIESempty (a query)
0x05TELEMETRY_TARGETsee below — set or clear the telemetry target
0x06TX_SPACINGon-air transmit spacing in ms, u16 BE (2 B)
0x07RADIO_QUERYempty (a query, #349) — answered with RADIO_REPORT
0x08FIXED_POSITIONsee below — set or clear the user-set position
0x09MEDIA_PROFILEone flag byte (bit0 lora, bit1 ble) — answered with MEDIA_REPORT
0x0AMEDIA_QUERYempty (a query) — answered with MEDIA_REPORT
0x0BPOSITION_SOURCE_QUERYempty (a query) — answered with POSITION_SOURCE_REPORT
0x0CNODE_NAMEsee below — set or clear the operator-chosen name
0x0DNODE_NAME_QUERYempty (a query) — answered with NODE_NAME_REPORT
0x0EIDENTITY_QUERYempty (a query) — answered with IDENTITY_REPORT
0x0FANNOUNCEempty — announce now: the LXMF delivery destination, and the propagation destination where that role runs (#376, #384)
0x10BLE_TX_GAPBLE inter-packet gap in ms, u16 BE (2 B), 0..=5000 (#376)
0x11STORE_STORMrecord count and body size, two u16 BE (4 B), 1..=1000 and 0..=1024 (#384)
0x12PN_CONFIGannounced stamp cost and required peering cost, one byte each (2 B); 0xFF in a field keeps the persisted value (#384)
0x13MGMT_ALLOWsee below — set or clear the remote-management allow-list (#235)
0x14MGMT_ALLOW_QUERYempty (a query) — answered with MGMT_ALLOW_REPORT

Responses (board → host):

typenamepayload
0x81ACK[acked_type]
0x82REFUSAL[refused_type, reason]
0x83CAPABILITY_REPORT[version, accepted types...]
0x84RADIO_REPORTthe RADIO_CONFIG parameter block the radio is running (#349)
0x85MEDIA_REPORT[running_flags, configured_flags] in the MEDIA_PROFILE flag encoding
0x86POSITION_SOURCE_REPORTone flag byte (bit0 fixed position set, bit1 GNSS built in and active)
0x87NODE_NAME_REPORT[flags, mesh_len, mesh…, ble_len, ble…] — see below
0x88IDENTITY_REPORT[flags, identity(16), probe(16), lxmf(16)], 49 B fixed
0x89MGMT_ALLOW_REPORT[flags, count, count × identity(16)] — see below
0x8ADUTY_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-clock on the debug port and, if it has nothing else to announce, refuses with reason 0x07. (The gate is not cosmetic: the emission timestamp inside the announce is what peers rank paths by — see docs/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:

idnamemeaning
0x00OFFclear the target — telemetry off
0x01TRACKERmovement-driven cadence
0x02STATIONslow 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:

flageffect
(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-telemetrysend profile 0x00 — clear whatever the board had stored
--set-telemetrythe 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

flageffect
--set-position LAT,LON[,ALT]set it on every running board, then exit; no flash
--clear-positionback 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:

  1. The first magic byte 0xA4 has 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.
  2. 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.
  3. 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: lnflash opens 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-time reports "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.