Spec §3.5 and §6.3 (SET_CONFIG side effects) previously required the host to re-issue RX_START after any successful SET_CONFIG, because the radio was unconditionally left in idle. That chore is hostile to scanner, time-division, and dynamic-modulation use cases and violates the principle of least surprise. Update the normative text so that SET_CONFIG applied while the radio is in continuous RX auto-re-arms RX with the new parameters once reconfiguration completes. RX ring is still cleared (stale packets from the old modulation are not semantically meaningful). The OK response is still held until the radio is ready, so hosts may TX / RX immediately after it. Behavior when RX was not active on SET_CONFIG is unchanged: radio lands in idle. No wire-format change. Old hosts that re-issue RX_START after SET_CONFIG keep working (the redundant call is a no-op OK on a radio already in Receiving).
92 KiB
DongLoRa Protocol v2 Specification
Version 1.0
A minimal, reliable, transport-agnostic wire protocol for exposing a LoRa radio transceiver as a USB-serial (or BLE / WiFi / TCP) device. This document is the normative reference for implementers of both host software and device firmware.
Table of Contents
- Overview
- Frame Format
- State Machine
- Message Types
- Common Data Types
- Message Reference
- Error Codes
- Radio Chip ID Enum
- Capability Bitmap
- Modulation Parameters
- Ordering and Interleaving
- Flow Control and Backpressure
- Multi-Client Operation (Future)
- Host Behavior Requirements
- Device Behavior Requirements
- Version Compatibility
- Appendix A — COBS Reference
- Appendix B — CRC Reference
- Appendix C — Wire Examples
- Appendix D — Glossary
1. Overview
DongLoRa Protocol is the wire protocol spoken between a host application and firmware on a microcontroller attached to a Semtech LoRa transceiver (SX126x, SX127x, SX128x, LR11xx, LR2021, or similar). It exposes the transceiver's native capabilities over a byte-stream transport with no editorialization: the host asks, the chip does.
1.1 Design Principles
- Minimal. Ten message types. No sub-protocols, no modes, no sessions.
- Transport-agnostic. Runs identically over USB CDC-ACM, USB-to-UART bridges, plain UART, BLE, TCP, or any other ordered byte stream.
- No persistent state. The device boots unconfigured and forgets everything on host disconnect. There is no device-side memory between sessions.
- No policy. The firmware does not enforce regulatory limits, retry on failure, reorder TX, or apply profiles. The host is authoritative for all policy.
- Binary. No text, no JSON, no AT commands. Every bit is accounted for.
- Host drives. The device never initiates a conversation. Every device-to-host frame is either a response to a command or a radio-sourced event.
1.2 Terminology
- Device: MCU + radio transceiver + firmware that speaks DongLoRa Protocol.
- Host: Any software endpoint speaking DongLoRa Protocol.
- Client: In the multi-client future, an individual host connection. A single-client USB deployment has one client, which is the host.
- Frame: A single DongLoRa Protocol message on the wire, COBS-encoded and delimited.
- Tag: 16-bit correlation identifier for matching responses to commands.
- CONFIGURED / UNCONFIGURED: The two states of the device's state machine (Section 3).
2. Frame Format
2.1 Layout
Every DongLoRa Protocol frame has this pre-encoding structure:
┌──────┬──────┬──────────┬───────┐
│ type │ tag │ payload │ crc16 │
│ u8 │ u16 │ 0..P │ u16 │
└──────┴──────┴──────────┴───────┘
P is the maximum payload size for the largest message type, computed as max_payload_bytes + 20 bytes (to accommodate the RX event's 20-byte metadata prefix plus the bytes field). max_payload_bytes is reported by the device in GET_INFO (Section 6.2) and reflects the chip's maximum over-the-air packet length.
The pre-encoding structure is then COBS-encoded (Section 2.4) and terminated with a single 0x00 byte on the wire:
┌────────────────────────────────────┬──────┐
│ COBS(type | tag | payload | crc16) │ 0x00 │
└────────────────────────────────────┴──────┘
All multi-byte integer fields are little-endian.
2.2 Fields
type (u8): Message type identifier. See Section 4.
tag (u16, little-endian):
- On host→device commands: a host-chosen correlation ID. MUST NOT be
0x0000. - On device→host command responses (
OK,ERR,TX_DONE): echoes the tag of the originating command. - On device→host asynchronous events (
RX, asynchronousERR): always0x0000.
Tag uniqueness: a tag is outstanding from the moment the host transmits the command until the host has received the final response for that tag (OK, ERR, or for TX the TX_DONE). Hosts MUST NOT reuse a tag that is currently outstanding. Once a tag is closed it may be reused freely. A monotonic counter (starting at 1, wrapping at 0xFFFF and skipping 0x0000) is the recommended implementation.
Tag = 0x0000 on a host→device command is a protocol violation. The device MUST treat such a frame as a framing error and discard it (emitting asynchronous ERR(EFRAME) per Section 2.5), because a response with tag 0x0000 would be indistinguishable from an asynchronous event. Hosts MUST never send tag 0x0000.
payload (variable length, 0 to max_payload_bytes + 20 bytes):
- Structure defined per message type (Section 6).
- Length is implicit from the frame boundary: the payload runs from the byte after the tag to two bytes before the end of the decoded frame.
crc16 (u16, little-endian):
- CRC-16/CCITT-FALSE, computed over
type || tag || payload(pre-COBS bytes, not including the CRC itself). - Polynomial:
0x1021 - Initial value:
0xFFFF - No reflection (input or output)
- XOR out:
0x0000 - Check value for ASCII
"123456789":0x29B1
This variant is also known as CRC-16/AUTOSAR or CRC-16/IBM-3740. It is NOT the same as CRC-16/KERMIT, CRC-16/XMODEM, or CRC-16/CCITT (which use different initial values or reflect their input).
2.3 Frame Size Bounds
- Minimum pre-COBS frame: 5 bytes (type + tag + CRC, empty payload).
- Maximum pre-COBS frame:
5 + max_payload_bytes + 20bytes. For a device reportingmax_payload_bytes = 255, this is 280 bytes. - COBS adds at most
ceil(pre_cobs_length / 254) + 1bytes of overhead. - Plus one
0x00delimiter byte.
Implementations SHOULD use buffer sizes of at least max_payload_bytes + 32 bytes to accommodate all frames comfortably.
2.4 COBS Encoding
Frames are encoded with Consistent Overhead Byte Stuffing (COBS), using 0x00 as the framing byte. This guarantees that 0x00 never appears within an encoded frame, so a single 0x00 reliably marks frame boundaries.
Algorithm: The standard COBS algorithm (Cheshire & Baker, 1999). Reference implementation in Appendix A.
Decoder resynchronization: A receiver that opens the stream mid-frame, or that has lost bytes, discards received bytes until the next 0x00 and begins decoding at the byte following that 0x00. No other synchronization mechanism is used.
Overhead: At most ceil(N / 254) + 1 bytes for a pre-encoded frame of N bytes, plus the trailing 0x00 delimiter. For typical frames (under 254 bytes), overhead is 2 bytes.
2.5 Receiver Parsing Algorithm
On receipt of a byte stream:
- Accumulate bytes until a
0x00byte is received. That0x00is the frame delimiter. - COBS-decode the bytes that preceded the delimiter.
- If the decoded length is less than 5 bytes, discard. Device MAY emit asynchronous
ERR(EFRAME). - Split the decoded bytes:
type(1) ||tag(2) ||payload(variable) ||crc16(2). - Compute CRC-16/CCITT-FALSE over
type || tag || payload. If it does not matchcrc16, discard. Device MAY emit asynchronousERR(EFRAME). - Dispatch by
type. Unknowntypebyte on the device side: emitERR(EUNKNOWN_CMD)with the received tag. Unknowntypeon the host side: silently discard.
A partial frame at stream close (bytes received without a terminating 0x00) is always discarded.
3. State Machine
The device has exactly two states: UNCONFIGURED and CONFIGURED.
┌──────────────────┐
│ UNCONFIGURED │ ← initial state on boot
└──────────────────┘
│ ▲
SET_CONFIG │ │ inactivity timeout (1 s)
result= │ │ OR client disconnect
APPLIED ▼ │
┌──────────────────┐
│ CONFIGURED │
└──────────────────┘
3.1 UNCONFIGURED
- Entered on boot, after inactivity timeout, or on final client disconnect.
- Radio is in standby (TX disabled, RX disabled, minimal current).
- TX queue is empty. RX ring is empty.
- Accepted commands:
PING,GET_INFO,SET_CONFIG. - Other commands (
TX,RX_START,RX_STOP) returnERR(ENOTCONFIGURED).
3.2 CONFIGURED
- Entered when
SET_CONFIGreturnsresult = APPLIED. - Radio parameters match the most recent successful
SET_CONFIGpayload. - All commands accepted.
- Radio is idle (no RX, no TX) unless
RX_STARThas been issued or a TX is in flight.
3.3 Transitions
UNCONFIGURED → CONFIGURED: successful SET_CONFIG with result = APPLIED.
CONFIGURED → UNCONFIGURED:
- Inactivity timer expires (no frames received for 1000 ms), OR
- Final client disconnects at the transport layer.
Note: SET_CONFIG results of ALREADY_MATCHED or LOCKED_MISMATCH do NOT transition state. They are responses observing the current configuration.
Within CONFIGURED: SET_CONFIG may be issued again to change params (subject to the lock in multi-client, Section 13). See Section 3.5 for operations spanning this transition.
3.4 Liveness Timer
- Each client maintains an inactivity timer with a fixed period of 1000 ms.
- The timer is inert until the first frame from that client has been received. A freshly-booted device with no activity does not time out. The timer starts when the first frame arrives and resets on every subsequent frame.
- The timer is reset whenever any byte stream activity results in a complete frame arriving, whether that frame parses successfully or not. Even a frame that fails CRC or COBS resets the timer — the bytes arrived, meaning the host is present.
- If the timer expires:
- All queued-but-not-started TXs issued by that client are discarded silently. No
TX_DONEis emitted. - Any TX on the air is allowed to complete (it cannot be cleanly aborted). No
TX_DONEis emitted. - In single-client: the device transitions to UNCONFIGURED. RX ring is cleared. Any continuous RX is aborted. The timer returns to the inert state until the next frame arrives.
- In multi-client: only that client's session state is cleaned up. See Section 13.
- All queued-but-not-started TXs issued by that client are discarded silently. No
A client whose inactivity timer has fired is considered disconnected. Transport-level disconnect events (USB detach, BLE disconnect, TCP close) have identical effect to timer expiration.
3.5 Operations Spanning Reconfiguration
SET_CONFIG while a TX is on the air: the in-progress TX is allowed to physically complete. Its TX_DONE is emitted normally. Any queued-but-not-yet-started TXs are cancelled and emit TX_DONE(result=CANCELLED). The RX ring is cleared. If the radio was in continuous RX when SET_CONFIG was issued, RX is automatically re-armed with the new parameters once the in-flight TX finishes and the radio has completed reconfiguration; the host does not need to re-issue RX_START. New configuration takes effect after the in-flight TX finishes.
SET_CONFIG validation failure (ERR(EPARAM), ERR(ELENGTH), ERR(EMODULATION)): the chip is not touched. All parameters are validated before any SPI write to the radio. If validation fails, the radio and state machine are unchanged.
SET_CONFIG with no lock conflict, applied successfully: the radio is driven through its configuration sequence (which includes any required image calibration, ~3–15 ms on SX126x). The OK response is not emitted until the radio has completed configuration and is ready to accept TX / RX commands.
Hardware failure during SET_CONFIG: If the radio does not respond to SPI commands or returns unexpected status during configuration, the device returns ERR(ERADIO) synchronously with the command's tag. The radio is placed in the safest reachable state (typically standby) and the device transitions to UNCONFIGURED. No follow-up asynchronous ERR is emitted for the same fault.
Disconnect during SET_CONFIG: If the client disconnects while the radio is being reconfigured (the ~15 ms calibration window), the configuration completes to the chip (it is not interrupted), the OK is generated and then silently dropped, and normal disconnect handling proceeds: the device transitions to UNCONFIGURED and re-idles the radio. No partial-configuration state is possible.
4. Message Types
| Hex | Direction | Name | Brief |
|---|---|---|---|
| 0x01 | H→D | PING | Liveness |
| 0x02 | H→D | GET_INFO | Query capabilities and identity |
| 0x03 | H→D | SET_CONFIG | Configure the radio |
| 0x04 | H→D | TX | Transmit a packet |
| 0x05 | H→D | RX_START | Begin continuous reception |
| 0x06 | H→D | RX_STOP | End continuous reception |
| 0x80 | D→H | OK | Positive response |
| 0x81 | D→H | ERR | Negative response or asynchronous fault |
| 0xC0 | D→H | RX | Packet received |
| 0xC1 | D→H | TX_DONE | TX concluded (transmitted/busy/cancelled) |
All other type byte values are reserved. Devices MUST reject unknown command types (bytes with MSB = 0) with ERR(EUNKNOWN_CMD) echoing the received tag. Hosts MUST silently discard frames with unknown device→host type bytes (MSB = 1).
5. Common Data Types
5.1 Scalar Encodings
| Name | Width | Encoding |
|---|---|---|
| u8 | 1 | Unsigned byte |
| u16 | 2 | Unsigned, little-endian |
| u32 | 4 | Unsigned, little-endian |
| u64 | 8 | Unsigned, little-endian |
| i8 | 1 | Signed byte, two's complement |
| i16 | 2 | Signed, little-endian, two's complement |
| i32 | 4 | Signed, little-endian, two's complement |
5.2 Physical-Unit Encodings
| Quantity | Encoding |
|---|---|
| Frequency | u32, hertz |
| TX power | i8, dBm |
| RSSI | i16, tenths of dBm (e.g., −1234 = −123.4 dBm) |
| SNR | i16, tenths of dB (e.g., −45 = −4.5 dB) |
| Frequency error | i32, hertz |
| Time interval | u32, microseconds |
| Monotonic time | u64, microseconds since device boot (does not wrap) |
5.3 Variable-Length Fields
Fields whose length is not explicitly given in a message's payload definition extend from their stated start offset to the end of the frame's payload. Their length is implicit from the total frame length.
Each message type has at most two variable-length fields, and they always appear last. When two appear together (e.g., the UIDs in GET_INFO response), explicit length prefixes disambiguate them.
6. Message Reference
6.1 PING (0x01, H→D)
Purpose: Keepalive. Sent by the host when it has no other work to do, to prevent the device's inactivity timer from expiring. May also be used to measure round-trip latency.
Payload: Empty (0 bytes).
Response: OK with empty payload, tag echoed.
Valid in: any state (UNCONFIGURED or CONFIGURED).
Errors: none specific.
6.2 GET_INFO (0x02, H→D)
Purpose: Query the device's identity, firmware version, radio type, capabilities, and queue capacities. Optional — the host is not required to call it. Typically called once, after connecting.
Payload: Empty.
Response: OK with the following payload, tag echoed:
Offset Size Field Description
0 1 proto_major u8, protocol major version (1 for this spec)
1 1 proto_minor u8, protocol minor version (0 for this spec)
2 1 fw_major u8, firmware major
3 1 fw_minor u8, firmware minor
4 1 fw_patch u8, firmware patch
5 2 radio_chip_id u16, see Section 8
7 8 capability_bitmap u64, see Section 9
15 2 supported_sf_bitmap u16, bit N = LoRa SF N is supported (N in 0..15)
17 2 supported_bw_bitmap u16, bit N = LoRa BW enum value N is supported (N in 0..15)
19 2 max_payload_bytes u16, max over-the-air payload
21 2 rx_queue_capacity u16, RX ring depth
23 2 tx_queue_capacity u16, TX queue depth
25 4 freq_min_hz u32, minimum carrier frequency the radio supports
29 4 freq_max_hz u32, maximum carrier frequency the radio supports
33 1 tx_power_min_dbm i8, minimum TX power (most negative)
34 1 tx_power_max_dbm i8, maximum TX power
35 1 mcu_uid_len u8, 0 to 32
36 N mcu_uid N = mcu_uid_len
36+N 1 radio_uid_len u8, 0 to 16
37+N M radio_uid M = radio_uid_len
Total payload size: 37 + mcu_uid_len + radio_uid_len bytes.
Valid in: any state.
Errors: none specific.
Notes:
radio_uid_lenMAY be 0 if the radio has no accessible unique identifier.mcu_uidandradio_uidare opaque byte strings; hosts SHOULD treat them as unique identifiers for the silicon and not interpret their internal structure.capability_bitmaptells the host which modulations and features this device supports before it attemptsSET_CONFIG.supported_sf_bitmapandsupported_bw_bitmapdescribe LoRa-specific capabilities. Each bit N corresponds to value N of the respective enum (SF index for SF, BW enum value for BW). Hosts SHOULD consult these before issuingSET_CONFIGto avoid trial-and-error. Rationale: some chips have gaps (e.g., LLCC68 supports SF5–SF11 but not SF12; SX127x supports SF6–SF12 but not SF5), which a min/max pair cannot express.- Both bitmaps are meaningful only when
capability_bitmapbit 0 (LoRa) is set. On non-LoRa-capable devices they MAY be zero. rx_queue_capacityandtx_queue_capacityhelp the host pipeline intelligently without overrunning the device.freq_min_hz/freq_max_hzbracket the RF front-end's supported range for this specific board. A chip's silicon may cover a wider range than the matching network permits, so these values reflect the board's effective tuning window.tx_power_min_dbm/tx_power_max_dbmbracket the chip's settable TX power register range. The host SHOULD validate itstx_power_dbmagainst these bounds before callingSET_CONFIG; values outside the range yieldERR(EPARAM).- All
GET_INFOfields are stable for the lifetime of a session. The device MUST NOT change them after boot. Hosts MAY cache the response for the duration of the connection. - If the firmware cannot identify its radio (e.g., SPI probe failed),
radio_chip_idis0x0000and the host SHOULD abort — the device will not function.
6.3 SET_CONFIG (0x03, H→D)
Purpose: Configure the radio for a given modulation and parameter set. This is the only command that can transition the device from UNCONFIGURED to CONFIGURED. It may also be issued in CONFIGURED to change parameters.
Payload:
Offset Size Field Description
0 1 modulation_id u8, see Section 10
1 N params Modulation-specific struct (Section 10)
The length of params is determined entirely by modulation_id (Section 10). Sending a payload whose total length does not match 1 + expected_param_size yields ERR(ELENGTH).
Response (on success): OK with the following payload, tag echoed:
Offset Size Field Description
0 1 result u8: 0=APPLIED, 1=ALREADY_MATCHED, 2=LOCKED_MISMATCH
1 1 owner u8: 0=NONE, 1=MINE, 2=OTHER
2 1 current_modulation u8, modulation currently in effect on the radio
3 N current_params Parameters currently in effect
current_modulation and current_params always reflect what is actually programmed into the radio at the moment the response is constructed, regardless of whether this call caused a change. This gives the host a consistent ground truth in every case.
Result values:
- APPLIED (0): The request was accepted. The radio is now configured as requested. The calling client owns the lock (single-client: always
MINE). - ALREADY_MATCHED (1): Another client holds the lock, but the active configuration byte-for-byte matches the requested one. No change was made. The calling client may proceed to use the radio cooperatively but does not own the lock.
- LOCKED_MISMATCH (2): Another client holds the lock and the active configuration differs. No change was made. The calling client may inspect
current_paramsto diff.
Owner values:
- NONE (0): No client currently holds the lock.
- MINE (1): The calling client holds the lock.
- OTHER (2): A different client holds the lock.
Valid in: any state.
Errors (all synchronous, with tag echoed):
ERR(EMODULATION):modulation_idis not supported on this chip. Check the capability bitmap.ERR(ELENGTH): payload length does not match the expected size formodulation_id.ERR(EPARAM): a parameter value is out of range (e.g., frequency outside chip's supported range, SF out of range, reserved field nonzero).
Atomicity: All parameters are validated before any SPI write is issued to the radio. On validation failure the radio is untouched; the device returns ERR and remains in its prior state (UNCONFIGURED or CONFIGURED with prior params). Validation cannot leave the radio in an intermediate state.
Side effects on APPLIED:
- Any in-progress TX is allowed to finish; its
TX_DONEis emitted normally. - All queued (not yet started) TXs are cancelled with
TX_DONE(result=CANCELLED). - RX ring is cleared.
- Radio is reconfigured and calibrated if required. If the radio was in continuous RX when
SET_CONFIGwas issued, RX is automatically re-armed with the new parameters once reconfiguration completes; otherwise the radio is left in idle. Either way, theOKresponse is not emitted until this sequence finishes, so the host may issue TX / RX commands immediately afterOK.
Notes:
- Auto-LDRO: For LoRa, the device automatically enables Low Data Rate Optimization when
(2^SF) / BW_Hz > 16 ms(the Semtech-recommended threshold). The host does not specify LDRO and is not informed of its state. - Reconfiguring to identical parameters is a valid call. In single-client mode, the response is always
result = APPLIED, owner = MINE. No short-circuit is performed; the radio is genuinely re-initialized. - In multi-client mode, see Section 13.2 for lock behavior.
6.4 TX (0x04, H→D)
Purpose: Transmit a single packet.
Payload:
Offset Size Field Description
0 1 flags u8, see below
1 N bytes Packet bytes, 1 ≤ N ≤ max_payload_bytes
Flags (bit 0 = LSB):
- Bit 0 —
skip_cad: If 1, skip the default CAD-before-TX and transmit immediately. If 0, the device performs a CAD cycle first and transmits only if the channel is clear (emittingTX_DONE(CHANNEL_BUSY)otherwise). Default behavior (bit 0 = 0) is the recommended path. - Bits 1–7: Reserved. MUST be 0. Device rejects frames with any reserved bit set via
ERR(EPARAM).
Response: OK with empty payload, tag echoed. This response indicates the command was accepted and enqueued. It does NOT indicate the packet was transmitted. The transmission outcome is delivered later as a TX_DONE event with the same tag.
Asynchronous completion: exactly one TX_DONE event with matching tag (Section 6.10), unless the command failed synchronously (received ERR, no TX_DONE follows) or the client disconnected before transmission (no TX_DONE emitted, since the client is gone).
Valid in: CONFIGURED only.
Errors:
ERR(ENOTCONFIGURED): device is UNCONFIGURED.ERR(ELENGTH): N = 0 (zero-byte packets are not permitted), N >max_payload_bytes, or N exceeds the current modulation's per-mode maximum (which may be less thanmax_payload_bytes).ERR(EPARAM): a reserved flag bit is set.ERR(EBUSY): internal TX queue is full (already holdstx_queue_capacitypending TXs). Host should wait for an outstandingTX_DONEbefore retrying with a new tag.
Max payload note: max_payload_bytes from GET_INFO is the chip-wide maximum. Individual modulations may have stricter limits (e.g., LR-FHSS payloads are ≤ ~100 bytes depending on configuration). A packet larger than the current modulation supports is rejected with ERR(ELENGTH), even if it fits within max_payload_bytes.
CAD-default behavior on non-CAD modulations: On modulations that do not support CAD (FSK, GFSK, FLRC, etc.), the skip_cad flag is silently ignored and the TX fires immediately. Host code does not change between LoRa and non-LoRa modes.
RX interruption by TX: On half-duplex chips (all Semtech transceivers except when capability bit 22 is set), the radio briefly leaves RX to transmit and returns to RX afterward. A packet that was mid-reception during the transmission window is lost. On full-duplex chips, TX does not interrupt RX.
CAD timing: For LoRa, the CAD duration depends on SF and BW. At SF7/BW125 it is ~4 ms; at SF12/BW125 it is ~130 ms. Hosts that need tight TX latency should either use a lower SF or set skip_cad = 1.
6.5 RX_START (0x05, H→D)
Purpose: Place the radio in continuous receive mode. Every received packet (including ones with bad CRC) produces an RX event.
Payload: Empty.
Response: OK with empty payload, tag echoed.
Valid in: CONFIGURED only.
Errors:
ERR(ENOTCONFIGURED): device is UNCONFIGURED.ERR(EMODULATION): the currently-configured modulation does not support reception (e.g., LR-FHSS is transmit-only on all current Semtech silicon).
Notes:
- Calling
RX_STARTwhen already in continuous RX is a no-op. Response isOK. - A
TXinterrupts RX, transmits, and the radio automatically returns to continuous RX afterward without a newRX_START. (On full-duplex chips, the interruption does not occur.) SET_CONFIGaborts continuous RX. Host must re-issueRX_STARTafter reconfiguration if it still wants to receive.
6.6 RX_STOP (0x06, H→D)
Purpose: End continuous reception; radio returns to idle.
Payload: Empty.
Response: OK with empty payload, tag echoed.
Valid in: CONFIGURED only.
Errors:
ERR(ENOTCONFIGURED): device is UNCONFIGURED.
Notes:
- Calling
RX_STOPwhen not in continuous RX is a no-op. Response isOK. - RX events already in the ring at the time of
RX_STOPremain queued; the host continues to receive them as normalRXframes until the ring drains.
6.7 OK (0x80, D→H)
Purpose: Positive response to a host command.
Tag: Echoes the originating command's tag. Never 0x0000.
Payload: Structure depends on originating command:
PING,TX,RX_START,RX_STOP: empty.GET_INFO: info struct (Section 6.2).SET_CONFIG: result / owner / current-config struct (Section 6.3).
Notes: To correctly interpret an OK payload, the host must know which command issued the echoed tag. Outstanding-tag bookkeeping is mandatory host-side.
6.8 ERR (0x81, D→H)
Purpose: Negative response to a command, OR asynchronous radio / protocol fault.
Tag:
- Non-zero → synchronous error, tag echoes the originating command. The command was not carried out and no compensating event will follow.
- Zero → asynchronous error, not tied to any specific command. Fanned out to all connected clients in multi-client mode.
Payload:
Offset Size Field Description
0 2 code u16, error code (Section 7)
6.9 RX (0xC0, D→H)
Purpose: Unsolicited event indicating a packet was received over the air (or, in multi-client mode, that another client transmitted and this is the loopback).
Tag: Always 0x0000.
Payload:
Offset Size Field Description
0 2 rssi i16, tenths of dBm
2 2 snr i16, tenths of dB
4 4 freq_err i32, hertz, estimated LO offset
8 8 timestamp_us u64, µs since boot, captured at RxDone IRQ (packet end)
16 1 crc_valid u8: 0=CRC failed, 1=CRC passed or CRC not configured
17 2 packets_dropped u16, packets dropped since previous delivered RX
19 1 origin u8: 0=over-the-air, 1=device-local loopback
20 N bytes Packet payload, N determined by frame length
Field semantics:
timestamp_usis captured the instant the radio asserts RxDone (the end-of-packet IRQ).crc_valid = 0packets are delivered to the host anyway, because they may still be of interest (debugging, partial capture). The bytes may be corrupt.- When
crc_valid = 0,snrandfreq_errmay be meaningless. packets_droppedis cleared to 0 on every deliveredRXevent. It represents losses since the last successful delivery.origin = 1is only possible in multi-client mode (Section 13). In single-client it is always 0.byteshas lengthN ≥ 0and always satisfiesN ≤ max_payload_bytes. If the radio reports a length field larger thanmax_payload_bytes(possible on a corrupted PHY header), the device MUST truncate tomax_payload_bytesand setcrc_valid = 0. Hosts can rely onN ≤ max_payload_bytesunconditionally.
6.10 TX_DONE (0xC1, D→H)
Purpose: Asynchronous completion of a previously-issued TX.
Tag: Echoes the tag of the originating TX.
Payload:
Offset Size Field Description
0 1 result u8: 0=TRANSMITTED, 1=CHANNEL_BUSY, 2=CANCELLED
1 4 airtime_us u32, on-air duration in microseconds (0 for non-TRANSMITTED)
Result values:
- TRANSMITTED (0): Packet went on the air.
airtime_usis the measured or computed time-on-air. - CHANNEL_BUSY (1): CAD detected activity; TX was aborted before transmission.
airtime_us = 0(CAD time itself is not counted). This result is only possible whenskip_cad = 0was set in the originating TX and the current modulation supports CAD (LoRa only). Any TX withskip_cad = 1, or any TX on a non-CAD modulation, will never produce CHANNEL_BUSY. - CANCELLED (2): TX was discarded because
SET_CONFIGor disconnect occurred before the packet reached the air.airtime_us = 0.
Notes:
- Exactly one
TX_DONEis emitted perTXthat received anOK. ATXthat receivedERRproduces noTX_DONE. - On client disconnect,
TX_DONEfor cancelled TXs is not emitted — the client is gone. - On
CHANNEL_BUSY, hosts should apply randomized backoff and retry with a new tag. The prior tag's state is closed and the same tag cannot be reused while any earlier-issued outstanding tag is still open.
7. Error Codes
All error codes are u16 little-endian. The tag on the enclosing ERR frame distinguishes context: non-zero tag = synchronous error tied to a specific command; tag 0x0000 = asynchronous error not tied to a command. The two tables below group codes by the context in which they are typically emitted, but the device MAY emit any code in either context when it fits semantically (e.g., ERR(ERADIO) is listed as asynchronous but is emitted synchronously when a hardware fault occurs during processing of a specific command, such as SPI failure during SET_CONFIG).
7.1 Synchronous (tag ≠ 0), typical
| Code | Name | Meaning |
|---|---|---|
| 0x0001 | EPARAM | A parameter value is out of range or invalid. |
| 0x0002 | ELENGTH | Payload length is wrong for the command or modulation. |
| 0x0003 | ENOTCONFIGURED | Command requires CONFIGURED; device is UNCONFIGURED. |
| 0x0004 | EMODULATION | Requested modulation is not supported by this chip. |
| 0x0005 | EUNKNOWN_CMD | Unknown command type byte. |
| 0x0006 | EBUSY | Transient: TX queue full. Host should wait and retry. |
Codes 0x0000–0x00FF are reserved for synchronous errors.
7.2 Asynchronous (tag = 0), typical
| Code | Name | Meaning |
|---|---|---|
| 0x0101 | ERADIO | Radio SPI error or unexpected hardware state. |
| 0x0102 | EFRAME | Inbound frame had bad CRC, bad COBS, or wrong length. |
| 0x0103 | EINTERNAL | Firmware encountered an unexpected internal condition. |
Codes 0x0100–0x01FF are reserved for asynchronous errors. Codes 0x0200 and above are reserved for future extensions.
Impact of asynchronous errors on pending commands: An EFRAME on a frame that was supposed to be a response leaves the host's corresponding outstanding tag open indefinitely from the device's perspective. Hosts MUST implement their own command timeouts (Section 14.2) to avoid tag exhaustion or indefinite waiting.
8. Radio Chip ID Enum
| Value | Chip | Family |
|---|---|---|
| 0x0000 | Unknown | — |
| 0x0001 | SX1261 | Gen 2 sub-GHz |
| 0x0002 | SX1262 | Gen 2 sub-GHz |
| 0x0003 | SX1268 | Gen 2 sub-GHz |
| 0x0004 | LLCC68 | Gen 2 sub-GHz |
| 0x0010 | SX1272 | Gen 1 sub-GHz |
| 0x0011 | SX1276 | Gen 1 sub-GHz |
| 0x0012 | SX1277 | Gen 1 sub-GHz |
| 0x0013 | SX1278 | Gen 1 sub-GHz |
| 0x0014 | SX1279 | Gen 1 sub-GHz |
| 0x0020 | SX1280 | Gen 2 2.4 GHz |
| 0x0021 | SX1281 | Gen 2 2.4 GHz |
| 0x0030 | LR1110 | Gen 3 multi-band |
| 0x0031 | LR1120 | Gen 3 multi-band |
| 0x0032 | LR1121 | Gen 3 multi-band |
| 0x0040 | LR2021 | Gen 4 multi-band |
Values not listed here are reserved. Future chips will be assigned in the next available slot of their family's range.
9. Capability Bitmap
The capability_bitmap field in GET_INFO is a u64. Bits are numbered from LSB. Undefined bits MUST be 0 in v1.0. Hosts MUST ignore undefined bits.
9.1 Modulations (bits 0–15)
| Bit | Modulation |
|---|---|
| 0 | LoRa |
| 1 | FSK |
| 2 | GFSK |
| 3 | LR-FHSS |
| 4 | FLRC |
| 5 | MSK |
| 6 | GMSK |
| 7 | BLE-compatible |
| 8–15 | Reserved |
A bit set here means the device's SET_CONFIG accepts the corresponding modulation_id.
Multiple modulation bits being set does NOT imply the device can operate in multiple modulations simultaneously. The radio is configured for exactly one modulation at a time (whichever was most recently applied via SET_CONFIG). To switch modulations, issue another SET_CONFIG.
9.2 Radio Features (bits 16–31)
| Bit | Feature |
|---|---|
| 16 | CAD-before-TX available (firmware performs CAD in default TX path) |
| 17 | IQ inversion supported in LoRa mode |
| 18 | Ranging / time-of-flight distance measurement |
| 19 | GNSS scanning |
| 20 | WiFi MAC scanning |
| 21 | Spectral scan |
| 22 | Full-duplex (simultaneous TX + RX) |
| 23–31 | Reserved |
If bit 16 is 0, the device does not perform CAD before TX even for LoRa mode; the skip_cad flag has no effect.
9.3 Protocol Features (bits 32–47)
| Bit | Feature |
|---|---|
| 32 | Multi-client support |
| 33–47 | Reserved |
9.4 Reserved (bits 48–63)
All reserved for future use.
10. Modulation Parameters
The modulation_id byte in SET_CONFIG selects one of the following parameter structs. Any value not listed here is reserved and yields ERR(EMODULATION) (if the chip conceptually supports the modulation but the ID is not yet defined) or ERR(EMODULATION) (if the chip does not support it at all).
| modulation_id | Modulation | Fixed struct size |
|---|---|---|
| 0x01 | LoRa | 15 bytes |
| 0x02 | FSK / GFSK | 16 + sync_word_len |
| 0x03 | LR-FHSS | 10 bytes |
| 0x04 | FLRC | 13 bytes |
10.1 LoRa (modulation_id = 0x01, 15 bytes)
Offset Size Field Range / notes
0 4 freq_hz u32, chip-supported range (EPARAM if outside)
4 1 sf u8, 5–12 (chip-dependent: SX127x is 6–12)
5 1 bw u8, bandwidth enum (see below)
6 1 cr u8, coding-rate enum (see below)
7 2 preamble_len u16, in symbols (typical min 6)
9 2 sync_word u16 (SX127x: uses low byte only; high byte MUST be 0)
11 1 tx_power_dbm i8, chip-dependent range
12 1 header_mode u8, 0=explicit, 1=implicit
13 1 payload_crc u8, 0=disabled, 1=enabled
14 1 iq_invert u8, 0=normal, 1=inverted
Bandwidth enum (the small integer on the wire, not the kHz value):
| Value | Bandwidth | Applicable chip families |
|---|---|---|
| 0 | 7.81 kHz | SX126x, SX127x, LR11xx, LR2021 |
| 1 | 10.42 kHz | SX126x, SX127x, LR11xx, LR2021 |
| 2 | 15.63 kHz | SX126x, SX127x, LR11xx, LR2021 |
| 3 | 20.83 kHz | SX126x, SX127x, LR11xx, LR2021 |
| 4 | 31.25 kHz | SX126x, SX127x, LR11xx, LR2021 |
| 5 | 41.67 kHz | SX126x, SX127x, LR11xx, LR2021 |
| 6 | 62.5 kHz | SX126x, SX127x, LR11xx, LR2021 |
| 7 | 125 kHz | all |
| 8 | 250 kHz | all |
| 9 | 500 kHz | all |
| 10 | 200 kHz | SX128x |
| 11 | 400 kHz | SX128x |
| 12 | 800 kHz | SX128x |
| 13 | 1600 kHz | SX128x |
A value not supported by the current radio yields ERR(EPARAM). Hosts SHOULD consult radio_chip_id and select from the applicable set.
Coding-rate enum:
| Value | Coding rate |
|---|---|
| 0 | 4/5 |
| 1 | 4/6 |
| 2 | 4/7 |
| 3 | 4/8 |
Any value outside these enumerations produces ERR(EPARAM).
10.2 FSK / GFSK (modulation_id = 0x02, variable size)
Offset Size Field Description
0 4 freq_hz u32, chip-supported range
4 4 bitrate_bps u32, bits per second
8 4 freq_dev_hz u32, FSK frequency deviation
12 1 rx_bw u8, chip-specific RX bandwidth enum
13 2 preamble_len u16, in bits
15 1 sync_word_len u8, 0–8
16 N sync_word N = sync_word_len bytes
Total struct size: 16 + sync_word_len bytes.
Sync word byte order: The byte at offset 16 of the payload is transmitted first on the air; subsequent bytes follow in order. Within each byte, the MSB is transmitted first (standard FSK/GFSK convention).
GFSK is selected by the firmware when the chip supports Gaussian filtering at the requested bitrate; the wire protocol does not distinguish it from FSK beyond the capability bitmap.
10.3 LR-FHSS (modulation_id = 0x03, 10 bytes)
Offset Size Field Description
0 4 freq_hz u32, center frequency
4 1 bw_enum u8, LR-FHSS occupied-bandwidth enum (see below)
5 1 cr_enum u8, LR-FHSS coding-rate enum (see below)
6 1 grid u8, 0=25.39 kHz grid, 1=3.9 kHz grid
7 1 hopping u8, 0=disabled, 1=enabled
8 1 tx_power_dbm i8
9 1 reserved u8, MUST be 0 (EPARAM otherwise)
LR-FHSS bandwidth enum:
| Value | Occupied BW |
|---|---|
| 0 | 39.06 kHz |
| 1 | 85.94 kHz |
| 2 | 136.72 kHz |
| 3 | 183.59 kHz |
| 4 | 335.94 kHz |
| 5 | 386.72 kHz |
| 6 | 722.66 kHz |
| 7 | 1523.44 kHz |
LR-FHSS coding-rate enum:
| Value | Coding rate |
|---|---|
| 0 | 5/6 |
| 1 | 2/3 |
| 2 | 1/2 |
| 3 | 1/3 |
10.4 FLRC (modulation_id = 0x04, 13 bytes)
Offset Size Field Description
0 4 freq_hz u32, carrier frequency
4 1 bitrate_enum u8, bitrate enum (see below)
5 1 cr_enum u8, FLRC coding-rate (0=1/2, 1=3/4, 2=1/1)
6 1 bt_enum u8, Gaussian BT product (0=off, 1=0.5, 2=1.0)
7 1 preamble_len u8, preamble length enum (see below)
8 4 sync_word u32, 32-bit sync word (transmitted MSB-first on air)
12 1 tx_power_dbm i8
FLRC bitrate enum:
| Value | Bitrate |
|---|---|
| 0 | 2600 kbps |
| 1 | 2080 kbps |
| 2 | 1300 kbps |
| 3 | 1040 kbps |
| 4 | 650 kbps |
| 5 | 520 kbps |
| 6 | 325 kbps |
| 7 | 260 kbps |
FLRC preamble length enum:
| Value | Preamble bits |
|---|---|
| 0 | 8 |
| 1 | 12 |
| 2 | 16 |
| 3 | 20 |
| 4 | 24 |
| 5 | 28 |
| 6 | 32 |
Any enum value outside these tables yields ERR(EPARAM).
FLRC specifics vary between SX128x and LR2021; the firmware maps chip-specific register values to these byte values. Values that don't correspond to a supported setting on the current chip produce ERR(EPARAM).
11. Ordering and Interleaving
These invariants define what hosts and devices may rely on.
Command responses are monotonic per session:
- If the host sends command A (tag = T_A) before command B (tag = T_B), then the device's response to A (
OKorERRwith tag T_A) is emitted before the response to B (OKorERRwith tag T_B). - This does NOT apply across clients in multi-client mode — each client sees its own command responses in order, but responses across clients may interleave arbitrarily.
TX_DONE order matches TX order:
- If TX with tag T_1 was accepted before TX with tag T_2 (same client), then
TX_DONE(T_1)is emitted beforeTX_DONE(T_2). - This follows from the serial nature of the TX queue.
RX and async events are unordered relative to responses:
RXevents and asynchronousERRevents (tag = 0) may appear at any point in the device→host stream, interleaved with command responses.- A host reading the stream cannot assume that a response to a pending command will arrive before an
RXevent, even if the command was issued first.
Exactly-once semantics:
- Every command that received
OKorERRis concluded (plus the additionalTX_DONEforTXthat gotOK). - No duplicated responses, no lost responses (absent transport-level failure).
12. Flow Control and Backpressure
12.1 Transport-Level Flow Control
DongLoRa Protocol relies on the underlying byte-stream transport to provide flow control:
- USB CDC-ACM / CDC-UART bridges: bulk endpoints provide implicit flow control via NAKs when the receiver is not ready.
- Plain UART: no flow control (host SHOULD use a reasonable bitrate like 921600 bps and respect device processing time).
- BLE: L2CAP / GATT credit-based flow control.
- TCP: window-based flow control.
If the device's internal receive buffer is full, it MAY stall the transport (stop ACKing / NAKing bytes) until buffer space is freed.
12.2 Device-Side Queue Limits
- TX queue: depth =
tx_queue_capacity(fromGET_INFO). Additional TX attempts returnERR(EBUSY). Host should wait for an outstandingTX_DONEbefore retrying. - RX ring: depth =
rx_queue_capacity. On overflow, oldest-first drop policy applies; the drop count is delivered in the nextRXevent'spackets_droppedfield.
12.3 Host-Side Expectations
Hosts SHOULD:
- Size their RX decoder buffer to at least
max_payload_bytes + 32bytes. - Not pipeline more commands than
tx_queue_capacity(for TX) without waiting for responses. - Drain the device→host stream continuously. A host that stops reading will eventually stall the device.
13. Multi-Client Operation (Future)
This section describes behavior on devices advertising capability bit 32 (multi-client). Single-client USB deployments may skip this section but note that multi-client-aware fields (result, owner, origin) appear on both and are defined consistently.
13.1 Per-Client Liveness
Each client has an independent 1000 ms inactivity timer. A client whose timer expires is disconnected; other clients are unaffected.
13.2 Configuration Lock
- The first
SET_CONFIGfrom any client, while no lock is held, acquires the lock. - The locking client may issue further
SET_CONFIGcommands freely; they always apply and returnresult=APPLIED, owner=MINE. - A non-locking client's
SET_CONFIG:- If requested params match active params byte-for-byte:
OK(result=ALREADY_MATCHED, owner=OTHER). No change. - Otherwise:
OK(result=LOCKED_MISMATCH, owner=OTHER). No change.
- If requested params match active params byte-for-byte:
- The lock is released when the locking client disconnects (transport-level OR liveness timeout). If other clients are still connected, the device remains in CONFIGURED with the current params until the next successful
SET_CONFIGfrom any client (which becomes the new locker). If no clients remain, the device transitions to UNCONFIGURED.
13.3 RX Distribution
RX events and asynchronous ERR events (tag = 0) are fanned out to every connected client.
13.4 TX Distribution and Cross-Client Loopback
- Each client's TXs go into a single global FIFO.
- Each client's
TX_DONEis delivered only to the originating client. - Each successful on-air TX (
TX_DONE(result=TRANSMITTED)) is additionally delivered to all other connected clients as anRXevent with:rssi,snr,freq_err= 0timestamp_us= captured at TxDone IRQcrc_valid= 1packets_dropped= 0origin= 1bytes= the transmitted packet
TXs that completed with CHANNEL_BUSY or CANCELLED did not go on the air and do NOT generate loopback events.
13.5 Single-Client Compatibility
In single-client mode:
owneris alwaysNONE(beforeSET_CONFIG) orMINE(after).resultis alwaysAPPLIEDfor successfulSET_CONFIG;ALREADY_MATCHEDandLOCKED_MISMATCHnever occur.originonRXis always 0.
13.6 External Multiplexer Pattern
DongLoRa Protocol intentionally does not carry a client identifier on the wire. When multi-client behavior is needed without firmware-side support, an external software multiplexer can provide it:
- The multiplexer holds one single-client DongLoRa Protocol connection to the dongle (the dongle's sole client).
- Upstream, the multiplexer accepts multiple connections from downstream applications.
- For each frame flowing upward from the dongle, the multiplexer dispatches to the owning upstream connection based on its own internal
device_tag → (upstream_connection, upstream_tag)mapping; for each frame flowing downward, the multiplexer rewrites the upstream-chosen tag into a fresh dongle-side tag and records the mapping. - If the multiplexer wants to expose multi-client semantics to its upstream clients (the
result/owner/originfields defined in this section), it implements the locking, echo, and fan-out logic itself — the dongle is unaware.
This is identical to the work firmware would do in Section 13.1–13.4, just implemented one layer up. When firmware later gains native multi-client support (capability bit 32), upstream applications can migrate from talking to the multiplexer to talking to the firmware directly without changing their DongLoRa Protocol implementation: the wire format is unchanged, and the transport connection itself serves as the client identity. See Section 13.7.
13.7 Client Identity is the Transport Connection
DongLoRa Protocol does not encode a client identifier in any frame. Instead, each transport-level connection (one USB CDC-ACM session, one BLE GATT link, one TCP socket) IS the client identity from the firmware's perspective. Firmware with native multi-client support (capability bit 32) accepts multiple simultaneous transport connections and maintains per-connection state internally: which client owns the config lock, which client enqueued each pending TX, which connection to deliver each RX event on.
Consequences:
- No HELO or handshake is needed. A client opens its transport and immediately begins sending DongLoRa Protocol frames.
- The wire format is identical between single-client and multi-client firmware. A host written against single-client DongLoRa Protocol works unchanged against multi-client firmware; fields whose values don't vary for single-client (
owner,result = ALREADY_MATCHED/LOCKED_MISMATCH,origin = 1) simply don't occur for that host. - Firmware implementing multi-client pays the complexity of per-connection bookkeeping; single-client firmware pays nothing. Neither is on the wire.
14. Host Behavior Requirements
14.1 MUST
- Send at least one frame every 1000 ms while connected, to prevent the device's inactivity timer from expiring.
PINGexists for this purpose. - Never use tag
0x0000on outbound commands. - Track outstanding tags; do not reuse a tag until its final response has been received (
OK,ERR, orTX_DONEas applicable). - Validate the CRC on every received frame; discard frames with mismatched CRC.
- Treat COBS decoding failures as framing errors; resynchronize on the next
0x00. - Set all reserved flag bits and reserved payload bytes to 0.
14.2 SHOULD
- Send keepalives at 500 ms intervals or better (2× margin on the 1000 ms device timeout).
- Use a monotonic tag counter starting at 1, wrapping at 0xFFFF and skipping 0x0000.
- Call
GET_INFOonce at connection to learnmax_payload_bytes,capability_bitmap, queue capacities, frequency range, TX power range, and identity. - Validate
freq_hzandtx_power_dbmlocally againstGET_INFO-reported ranges before issuingSET_CONFIG. - Apply a per-command timeout to detect hung or missing responses. A reasonable default: 2000 ms for commands that do not involve airtime; for
TX, 2000 ms + the computed packet airtime + maximum expected CAD duration. - On
TX_DONE(CHANNEL_BUSY), apply randomized exponential backoff before retrying with a new tag. - On
RX(packets_dropped > 0), log or surface the loss. - On receiving an unexpected
ERR(ENOTCONFIGURED)in response to a command that was issued while the host believed the device to be CONFIGURED, treat this as a lost-session signal: the device has timed out or rebooted. Re-issueSET_CONFIG, thenRX_STARTif continuous receive was previously active. Any TXs that were in-flight at the time are presumed not to have been transmitted.
14.3 MUST NOT
- Send commands with any reserved flag bit or reserved payload byte set.
- Assume any device-side state persists across disconnects.
- Assume response ordering across tags beyond what Section 11 guarantees.
15. Device Behavior Requirements
15.1 MUST
- Boot into UNCONFIGURED state.
- Reject unknown command types with
ERR(EUNKNOWN_CMD)echoing the tag. - Validate every
SET_CONFIGparameter before writing to the radio. On validation failure the chip is not touched and anERRis returned. - Auto-enable LDRO on LoRa when
(2^SF) / BW_Hz > 16 ms. - Echo the command's tag on every
OK,ERR, andTX_DONEresponse. - Use tag
0x0000forRXevents and asynchronousERRevents. - Reset the inactivity timer on every received frame, including frames that fail CRC or COBS decoding (the bytes arrived — the host is present).
- Emit exactly one of
OK/ERRper command, then exactly oneTX_DONEadditionally forTXcommands that receivedOK. - Block the
OKresponse toSET_CONFIGuntil the radio is fully configured and ready for subsequent commands (including any required calibration).
15.2 SHOULD
- Emit asynchronous
ERR(EFRAME)when an inbound frame fails CRC or COBS decoding, to aid host-side diagnosis. - Preserve the RX ring across TX operations (TX briefly interrupts RX, but queued RX events are not cleared).
- Use a display (if present) to reflect state: splash when UNCONFIGURED, connected status when CONFIGURED. Display behavior is not part of the protocol.
15.3 MUST NOT
- Persist any configuration, session state, tag state, or queue contents across reboots or disconnects.
- Initiate communication with the host (never send frames when the host has sent none).
- Enforce regulatory limits (TX power caps, duty cycle, band restrictions). The device is a transparent passthrough; regulatory compliance is the host's responsibility.
- Silently drop or modify packet payloads on TX or RX.
16. Version Compatibility
16.1 Protocol Version
GET_INFO.proto_major and GET_INFO.proto_minor identify the protocol version the device implements. This document specifies protocol version 1.0 (proto_major = 1, proto_minor = 0).
16.2 Major Version Changes
Different major versions are wire-incompatible. A host that does not recognize the device's reported proto_major MUST NOT attempt to use the device.
16.3 Minor Version Changes
Minor version increments are backward-compatible: they may add new message types, new capability bits, new modulations, new error codes, or new trailing fields on existing payloads. A host SHOULD continue to work with a device reporting a higher minor version, but will not have access to features added after its implementation.
16.4 Rules for Minor Version Extensions
Future minor versions MAY:
- Define new values in reserved ranges (message types, error codes, modulation IDs, chip IDs, capability bits).
- Append new fields to the end of existing payloads. Implementations use the frame length to determine the payload boundary; fields beyond what they understand are ignored.
Future minor versions MUST NOT:
- Change the meaning, encoding, or position of any existing field.
- Remove or renumber existing message types, error codes, chip IDs, modulation IDs, or capability bits.
- Change the semantics of existing commands or events.
Appendix A — COBS Reference Implementation
#include <stdint.h>
#include <stddef.h>
// Encode src[0..src_len-1] to dst. Returns bytes written to dst.
// dst must have room for src_len + ceil(src_len/254) + 1 bytes.
size_t cobs_encode(const uint8_t *src, size_t src_len, uint8_t *dst) {
size_t r = 0, w = 1;
size_t code_idx = 0;
uint8_t code = 1;
while (r < src_len) {
if (src[r] == 0) {
dst[code_idx] = code;
code_idx = w++;
code = 1;
} else {
dst[w++] = src[r];
code++;
if (code == 0xFF) {
dst[code_idx] = code;
code_idx = w++;
code = 1;
}
}
r++;
}
dst[code_idx] = code;
return w;
}
// Decode src[0..src_len-1] to dst. Returns bytes written to dst,
// or 0 on decode error (corrupt input).
size_t cobs_decode(const uint8_t *src, size_t src_len, uint8_t *dst) {
size_t r = 0, w = 0;
while (r < src_len) {
uint8_t code = src[r++];
if (code == 0 || r + code - 1 > src_len) return 0;
for (uint8_t i = 1; i < code; i++) dst[w++] = src[r++];
if (code != 0xFF && r < src_len) dst[w++] = 0;
}
return w;
}
Appendix B — CRC-16/CCITT-FALSE Reference Implementation
#include <stdint.h>
#include <stddef.h>
uint16_t crc16_ccitt_false(const uint8_t *data, size_t len) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; i++) {
crc ^= ((uint16_t)data[i]) << 8;
for (int b = 0; b < 8; b++) {
if (crc & 0x8000) crc = (crc << 1) ^ 0x1021;
else crc <<= 1;
}
}
return crc;
}
Test vector: crc16_ccitt_false("123456789", 9) = 0x29B1.
Appendix C — Wire Examples
C.1 Reading these examples
Every example below gives:
- Direction and type:
H→DorD→H, followed by the message name and the tag in hex. - Decoded field values (for messages with structured payloads): what each field means, not just raw bytes.
- Pre-COBS body: the
type | tag | payloadbytes, then a| crc=0x....marker, then the full pre-encoding frametype | tag | payload | crc_lo crc_hi. - Wire bytes: the COBS-encoded frame followed by the trailing
0x00delimiter. This is what actually moves over the transport.
All multi-byte integers are little-endian. All examples were generated by running the reference implementations in Appendices A and B against the specified input, and validated by round-trip.
Conventions used throughout:
- Tag values are chosen per section to keep examples distinct across the appendix. Real implementations use a monotonic counter from 1, skipping 0.
- Error cases assume the device is in the state described by the section intro (CONFIGURED unless noted otherwise).
...in decoded fields means "same as previously shown, unchanged". Byte dumps are always complete.
C.2 Basic sequences (happy path)
C.2.1 PING exchange
The simplest possible DongLoRa Protocol transaction. Host sends PING, device responds with OK.
H→D PING tag=0x0001
pre-COBS: 01 01 00 | crc=0xc89d → 01 01 00 9D C8
on wire: 03 01 01 03 9D C8 00
D→H OK tag=0x0001
pre-COBS: 80 01 00 | crc=0xc4f7 → 80 01 00 F7 C4
on wire: 03 80 01 03 F7 C4 00
C.2.2 GET_INFO exchange
Host queries device capabilities. Response describes an SX1262-based board with LoRa, FSK, and CAD; a 64-slot RX ring; a 16-deep TX queue; 150–960 MHz tuning; and −9 to +22 dBm TX power.
H→D GET_INFO tag=0x0002
pre-COBS: 02 02 00 | crc=0xc49e → 02 02 00 9E C4
on wire: 03 02 02 03 9E C4 00
D→H OK tag=0x0002
proto_major: 1
proto_minor: 0
fw_major: 0
fw_minor: 1
fw_patch: 0
radio_chip_id: 0x0002 (SX1262)
capability_bitmap: 0x0000000000010003 (bits 0=LoRa, 1=FSK, 16=CAD)
supported_sf_bitmap: 0x1FE0 (bits 5..12 = SF5..SF12)
supported_bw_bitmap: 0x03FF (all sub-GHz BW enum values 0..9)
max_payload_bytes: 255
rx_queue_capacity: 64
tx_queue_capacity: 16
freq_min_hz: 150000000 (150 MHz)
freq_max_hz: 960000000 (960 MHz)
tx_power_min_dbm: -9
tx_power_max_dbm: 22
mcu_uid_len: 8
mcu_uid: DE AD BE EF 01 23 45 67
radio_uid_len: 0
radio_uid: (empty)
pre-COBS: 80 02 00 01 00 00 01 00 02 00 03 00 01 00 00 00 00 00 E0 1F FF 03 FF 00 40 00 10 00 80 D1 F0 08 00 70 38 39 F7 16 08 DE AD BE EF 01 23 45 67 00 | crc=0xa4fa → 80 02 00 01 00 00 01 00 02 00 03 00 01 00 00 00 00 00 E0 1F FF 03 FF 00 40 00 10 00 80 D1 F0 08 00 70 38 39 F7 16 08 DE AD BE EF 01 23 45 67 00 FA A4
on wire: 03 80 02 02 01 01 02 01 02 02 02 03 02 01 01 01 01 01 06 E0 1F FF 03 FF 02 40 02 10 05 80 D1 F0 08 0F 70 38 39 F7 16 08 DE AD BE EF 01 23 45 67 03 FA A4 00
C.2.3 SET_CONFIG for LoRa (EU868, SF7, BW125, CR 4/5)
Standard LoRa configuration on EU868.1 MHz. Device applies and echoes the current params.
H→D SET_CONFIG tag=0x0003
modulation_id: 0x01 (LoRa)
freq_hz: 868100000 (868.1 MHz)
sf: 7
bw: 7 (125 kHz)
cr: 0 (4/5)
preamble_len: 8
sync_word: 0x1424
tx_power_dbm: 14
header_mode: 0 (explicit)
payload_crc: 1 (on)
iq_invert: 0 (normal)
pre-COBS: 03 03 00 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 | crc=0x1fd9 → 03 03 00 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 D9 1F
on wire: 03 03 03 08 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 D9 1F 00
D→H OK tag=0x0003
result: 0 (APPLIED)
owner: 1 (MINE)
current_modulation: 0x01 (LoRa)
current_params: [same 15 bytes echoed back]
pre-COBS: 80 03 00 00 01 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 | crc=0x91c8 → 80 03 00 00 01 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 C8 91
on wire: 03 80 03 01 09 01 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 C8 91 00
C.2.4 TX with default CAD, packet "Hello"
Assumes C.2.3 has run. Host transmits 48 65 6C 6C 6F ("Hello"). Device ACKs synchronously, performs CAD (~4 ms at SF7/BW125), transmits, and reports completion asynchronously. Airtime 30.976 ms = preamble (12.544 ms) + 18 payload symbols × 1.024 ms/symbol.
H→D TX tag=0x0004
flags: 0x00 (CAD enabled)
data: 48 65 6C 6C 6F ("Hello")
pre-COBS: 04 04 00 00 48 65 6C 6C 6F | crc=0x4026 → 04 04 00 00 48 65 6C 6C 6F 26 40
on wire: 03 04 04 01 08 48 65 6C 6C 6F 26 40 00
D→H OK tag=0x0004
pre-COBS: 80 04 00 | crc=0x3b02 → 80 04 00 02 3B
on wire: 03 80 04 03 02 3B 00
D→H TX_DONE tag=0x0004
result: 0 (TRANSMITTED)
airtime_us: 30976 (30.976 ms)
pre-COBS: C1 04 00 00 00 79 00 00 | crc=0xfae3 → C1 04 00 00 00 79 00 00 E3 FA
on wire: 03 C1 04 01 01 02 79 01 03 E3 FA 00
C.2.5 TX with skip_cad, packet "URGENT"
Host sets skip_cad to bypass CAD — typical for time-critical ACKs in protocols with their own MAC. No CAD time; straight to TX.
H→D TX tag=0x0005
flags: 0x01 (skip_cad set)
data: 55 52 47 45 4E 54 ("URGENT")
pre-COBS: 04 05 00 01 55 52 47 45 4E 54 | crc=0x1cdb → 04 05 00 01 55 52 47 45 4E 54 DB 1C
on wire: 03 04 05 0A 01 55 52 47 45 4E 54 DB 1C 00
D→H OK tag=0x0005
pre-COBS: 80 05 00 | crc=0x0833 → 80 05 00 33 08
on wire: 03 80 05 03 33 08 00
D→H TX_DONE tag=0x0005
result: 0 (TRANSMITTED)
airtime_us: 33792 (no CAD; straight to TX)
pre-COBS: C1 05 00 00 00 84 00 00 | crc=0xe381 → C1 05 00 00 00 84 00 00 81 E3
on wire: 03 C1 05 01 01 02 84 01 03 81 E3 00
C.2.6 Continuous RX loop
Host enters continuous RX. Device emits RX events as packets arrive. Host sends a periodic PING to keep the session alive, then eventually stops RX.
H→D RX_START tag=0x0006
pre-COBS: 05 06 00 | crc=0x8dca → 05 06 00 CA 8D
on wire: 03 05 06 03 CA 8D 00
D→H OK tag=0x0006
pre-COBS: 80 06 00 | crc=0x5d60 → 80 06 00 60 5D
on wire: 03 80 06 03 60 5D 00
D→H RX tag=0x0000
rssi: -735 (-73.5 dBm)
snr: 95 (+9.5 dB)
freq_err: -125 (-125 Hz)
timestamp_us: 42000000 (42.000 s since boot)
crc_valid: 1 (pass)
packets_dropped: 0
origin: 0 (over-the-air)
data: 01 02 03 04
pre-COBS: C0 00 00 21 FD 5F 00 83 FF FF FF 80 DE 80 02 00 00 00 00 01 00 00 00 01 02 03 04 | crc=0x8eb9 → C0 00 00 21 FD 5F 00 83 FF FF FF 80 DE 80 02 00 00 00 00 01 00 00 00 01 02 03 04 B9 8E
on wire: 02 C0 01 04 21 FD 5F 09 83 FF FF FF 80 DE 80 02 01 01 01 02 01 01 01 07 01 02 03 04 B9 8E 00
H→D PING tag=0x0007
pre-COBS: 01 07 00 | crc=0x623b → 01 07 00 3B 62
on wire: 03 01 07 03 3B 62 00
D→H OK tag=0x0007
pre-COBS: 80 07 00 | crc=0x6e51 → 80 07 00 51 6E
on wire: 03 80 07 03 51 6E 00
H→D RX_STOP tag=0x0008
pre-COBS: 06 08 00 | crc=0xf795 → 06 08 00 95 F7
on wire: 03 06 08 03 95 F7 00
D→H OK tag=0x0008
pre-COBS: 80 08 00 | crc=0x7e6f → 80 08 00 6F 7E
on wire: 03 80 08 03 6F 7E 00
C.3 Pipelining and batching
C.3.1 Three pipelined TXs
Host issues three TXs without waiting for intermediate responses. Each gets OK synchronously as it is enqueued; TX_DONE events arrive later in submission order.
H→D TX tag=0x0064 (data: 00 41 = flags + "A")
pre-COBS: 04 64 00 00 41 | crc=0x53cc → 04 64 00 00 41 CC 53
on wire: 03 04 64 01 04 41 CC 53 00
H→D TX tag=0x0065 (data: 00 42 = flags + "B")
pre-COBS: 04 65 00 00 42 | crc=0x151b → 04 65 00 00 42 1B 15
on wire: 03 04 65 01 04 42 1B 15 00
H→D TX tag=0x0066 (data: 00 43 = flags + "C")
pre-COBS: 04 66 00 00 43 | crc=0x9ee6 → 04 66 00 00 43 E6 9E
on wire: 03 04 66 01 04 43 E6 9E 00
D→H OK tag=0x0064
pre-COBS: 80 64 00 | crc=0x3028 → 80 64 00 28 30
on wire: 03 80 64 03 28 30 00
D→H OK tag=0x0065
pre-COBS: 80 65 00 | crc=0x0319 → 80 65 00 19 03
on wire: 03 80 65 03 19 03 00
D→H OK tag=0x0066
pre-COBS: 80 66 00 | crc=0x564a → 80 66 00 4A 56
on wire: 03 80 66 03 4A 56 00
D→H TX_DONE tag=0x0064 (result=0, airtime=25000 us)
pre-COBS: C1 64 00 00 A8 61 00 00 | crc=0xcc8e → C1 64 00 00 A8 61 00 00 8E CC
on wire: 03 C1 64 01 03 A8 61 01 03 8E CC 00
D→H TX_DONE tag=0x0065
pre-COBS: C1 65 00 00 A8 61 00 00 | crc=0x74ef → C1 65 00 00 A8 61 00 00 EF 74
on wire: 03 C1 65 01 03 A8 61 01 03 EF 74 00
D→H TX_DONE tag=0x0066
pre-COBS: C1 66 00 00 A8 61 00 00 | crc=0xac6d → C1 66 00 00 A8 61 00 00 6D AC
on wire: 03 C1 66 01 03 A8 61 01 03 6D AC 00
C.3.2 Batched frames in a single transport write (narrative)
A host writing three small frames in quick succession may see them concatenated into a single USB bulk transfer, one BLE notification, or one TCP packet. The byte stream is:
[frame1 wire bytes] [frame2 wire bytes] [frame3 wire bytes]
For the C.3.1 example, the first three TX frames batched look like:
03 04 64 01 04 41 CC 53 00 03 04 65 01 04 42 1B 15 00 03 04 66 01 04 43 E6 9E 00
That is, 27 bytes of raw transport data containing three DongLoRa Protocol frames. The receiver's COBS delimiter scanner handles this naturally: it reads bytes into a frame buffer until 0x00, decodes that frame, flushes the buffer, and continues. No additional framing or length-prefix is involved.
C.4 State transitions
C.4.1 Reconfigure while RX is active
Host is in continuous RX, receives a packet, then reconfigures (changing SF from 7 to 9). Device aborts RX, clears the ring, reconfigures, and returns to standby. Host must re-issue RX_START to resume.
H→D RX_START tag=0x000A
pre-COBS: 05 0A 00 | crc=0xc8a7 → 05 0A 00 A7 C8
on wire: 03 05 0A 03 A7 C8 00
D→H OK tag=0x000A
pre-COBS: 80 0A 00 | crc=0x180d → 80 0A 00 0D 18
on wire: 03 80 0A 03 0D 18 00
D→H RX tag=0x0000
rssi: -820, snr: 80, freq_err: 0, timestamp_us: 50000000,
crc_valid: 1, packets_dropped: 0, origin: 0, data: AA BB
pre-COBS: C0 00 00 CC FC 50 00 00 00 00 00 80 F0 FA 02 00 00 00 00 01 00 00 00 AA BB | crc=0xc054 → C0 00 00 CC FC 50 00 00 00 00 00 80 F0 FA 02 00 00 00 00 01 00 00 00 AA BB 54 C0
on wire: 02 C0 01 04 CC FC 50 01 01 01 01 05 80 F0 FA 02 01 01 01 02 01 01 01 05 AA BB 54 C0 00
H→D SET_CONFIG tag=0x000B (LoRa, SF=9, other params same as C.2.3)
pre-COBS: 03 0B 00 01 A0 27 BE 33 09 07 00 08 00 24 14 0E 00 01 00 | crc=0xbdcc → 03 0B 00 01 A0 27 BE 33 09 07 00 08 00 24 14 0E 00 01 00 CC BD
on wire: 03 03 0B 08 01 A0 27 BE 33 09 07 02 08 04 24 14 0E 02 01 03 CC BD 00
D→H OK tag=0x000B
result: 0 (APPLIED), owner: 1 (MINE), current: LoRa SF=9 ...
pre-COBS: 80 0B 00 00 01 01 A0 27 BE 33 09 07 00 08 00 24 14 0E 00 01 00 | crc=0x7f0b → 80 0B 00 00 01 01 A0 27 BE 33 09 07 00 08 00 24 14 0E 00 01 00 0B 7F
on wire: 03 80 0B 01 09 01 01 A0 27 BE 33 09 07 02 08 04 24 14 0E 02 01 03 0B 7F 00
(radio is now idle; host must re-start RX)
H→D RX_START tag=0x000C
pre-COBS: 05 0C 00 | crc=0x6201 → 05 0C 00 01 62
on wire: 03 05 0C 03 01 62 00
D→H OK tag=0x000C
pre-COBS: 80 0C 00 | crc=0xb2ab → 80 0C 00 AB B2
on wire: 03 80 0C 03 AB B2 00
C.4.2 Reconfigure with pending TXs — they are cancelled
Host queues two TXs, then before they complete, sends SET_CONFIG. Both queued TXs emit TX_DONE(CANCELLED). The SET_CONFIG then applies.
H→D TX tag=0x0014 (data = "first")
pre-COBS: 04 14 00 00 66 69 72 73 74 | crc=0x1dc9 → 04 14 00 00 66 69 72 73 74 C9 1D
on wire: 03 04 14 01 08 66 69 72 73 74 C9 1D 00
D→H OK tag=0x0014
pre-COBS: 80 14 00 | crc=0x3871 → 80 14 00 71 38
on wire: 03 80 14 03 71 38 00
H→D TX tag=0x0015 (data = "second")
pre-COBS: 04 15 00 00 73 65 63 6F 6E 64 | crc=0xa89a → 04 15 00 00 73 65 63 6F 6E 64 9A A8
on wire: 03 04 15 01 09 73 65 63 6F 6E 64 9A A8 00
D→H OK tag=0x0015
pre-COBS: 80 15 00 | crc=0x0b40 → 80 15 00 40 0B
on wire: 03 80 15 03 40 0B 00
H→D SET_CONFIG tag=0x0016 (LoRa SF=10 — different from current config)
pre-COBS: 03 16 00 01 A0 27 BE 33 0A 07 00 08 00 24 14 0E 00 01 00 | crc=0xc630 → 03 16 00 01 A0 27 BE 33 0A 07 00 08 00 24 14 0E 00 01 00 30 C6
on wire: 03 03 16 08 01 A0 27 BE 33 0A 07 02 08 04 24 14 0E 02 01 03 30 C6 00
D→H TX_DONE tag=0x0014
result: 2 (CANCELLED), airtime_us: 0
pre-COBS: C1 14 00 02 00 00 00 00 | crc=0xcf82 → C1 14 00 02 00 00 00 00 82 CF
on wire: 03 C1 14 02 02 01 01 01 03 82 CF 00
D→H TX_DONE tag=0x0015
result: 2 (CANCELLED), airtime_us: 0
pre-COBS: C1 15 00 02 00 00 00 00 | crc=0x77e3 → C1 15 00 02 00 00 00 00 E3 77
on wire: 03 C1 15 02 02 01 01 01 03 E3 77 00
D→H OK tag=0x0016
result: 0 (APPLIED), owner: 1 (MINE), current: LoRa SF=10 ...
pre-COBS: 80 16 00 00 01 01 A0 27 BE 33 0A 07 00 08 00 24 14 0E 00 01 00 | crc=0x3264 → 80 16 00 00 01 01 A0 27 BE 33 0A 07 00 08 00 24 14 0E 00 01 00 64 32
on wire: 03 80 16 01 09 01 01 A0 27 BE 33 0A 07 02 08 04 24 14 0E 02 01 03 64 32 00
C.4.3 Inactivity timeout and recovery
Host goes silent for more than 1000 ms. Device transitions to UNCONFIGURED. When host returns and tries to TX, it gets ERR(ENOTCONFIGURED). Host re-issues SET_CONFIG to recover.
H→D TX tag=0x001E (data = "before")
pre-COBS: 04 1E 00 00 62 65 66 6F 72 65 | crc=0x197f → 04 1E 00 00 62 65 66 6F 72 65 7F 19
on wire: 03 04 1E 01 09 62 65 66 6F 72 65 7F 19 00
D→H OK tag=0x001E
pre-COBS: 80 1E 00 | crc=0xd7ba → 80 1E 00 BA D7
on wire: 03 80 1E 03 BA D7 00
D→H TX_DONE tag=0x001E (TRANSMITTED, airtime 30976 us)
pre-COBS: C1 1E 00 00 00 79 00 00 | crc=0x3ed6 → C1 1E 00 00 00 79 00 00 D6 3E
on wire: 03 C1 1E 01 01 02 79 01 03 D6 3E 00
[ host sleeps 1.5 s — no frames sent ]
[ device's inactivity timer expires → UNCONFIGURED ]
H→D TX tag=0x001F (data = "after")
pre-COBS: 04 1F 00 00 61 66 74 65 72 | crc=0x03ef → 04 1F 00 00 61 66 74 65 72 EF 03
on wire: 03 04 1F 01 08 61 66 74 65 72 EF 03 00
D→H ERR tag=0x001F
code: 0x0003 (ENOTCONFIGURED)
pre-COBS: 81 1F 00 03 00 | crc=0x0397 → 81 1F 00 03 00 97 03
on wire: 03 81 1F 02 03 03 97 03 00
(host realises session is lost; re-configures)
H→D SET_CONFIG tag=0x0020 (LoRa SF=7, as before)
pre-COBS: 03 20 00 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 | crc=0xea74 → 03 20 00 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 74 EA
on wire: 03 03 20 08 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 74 EA 00
D→H OK tag=0x0020 (APPLIED, MINE)
pre-COBS: 80 20 00 00 01 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 | crc=0x19bb → 80 20 00 00 01 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 BB 19
on wire: 03 80 20 01 09 01 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 BB 19 00
H→D TX tag=0x0021 (retry)
pre-COBS: 04 21 00 00 61 66 74 65 72 | crc=0xdb22 → 04 21 00 00 61 66 74 65 72 22 DB
on wire: 03 04 21 01 08 61 66 74 65 72 22 DB 00
D→H OK tag=0x0021
pre-COBS: 80 21 00 | crc=0xc211 → 80 21 00 11 C2
on wire: 03 80 21 03 11 C2 00
D→H TX_DONE tag=0x0021 (TRANSMITTED, airtime 30976 us)
pre-COBS: C1 21 00 00 00 79 00 00 | crc=0xedb2 → C1 21 00 00 00 79 00 00 B2 ED
on wire: 03 C1 21 01 01 02 79 01 03 B2 ED 00
C.5 Error responses
C.5.1 TX attempted in UNCONFIGURED state
Host sends TX without first calling SET_CONFIG. Device returns ERR(ENOTCONFIGURED).
H→D TX tag=0x0028 (data = "hi")
pre-COBS: 04 28 00 00 68 69 | crc=0x7d24 → 04 28 00 00 68 69 24 7D
on wire: 03 04 28 01 05 68 69 24 7D 00
D→H ERR tag=0x0028
code: 0x0003 (ENOTCONFIGURED)
pre-COBS: 81 28 00 03 00 | crc=0x7e53 → 81 28 00 03 00 53 7E
on wire: 03 81 28 02 03 03 53 7E 00
C.5.2 TX with empty payload data
Host sends TX with only the flags byte and no data. Device returns ERR(ELENGTH) because data must be at least 1 byte.
H→D TX tag=0x0029
flags: 0
data: (empty — invalid)
pre-COBS: 04 29 00 00 | crc=0x5666 → 04 29 00 00 66 56
on wire: 03 04 29 01 03 66 56 00
D→H ERR tag=0x0029
code: 0x0002 (ELENGTH)
pre-COBS: 81 29 00 02 00 | crc=0x3bd6 → 81 29 00 02 00 D6 3B
on wire: 03 81 29 02 02 03 D6 3B 00
C.5.3 TX with reserved flag bit set
Host sets bit 1 of flags (reserved). Device returns ERR(EPARAM).
H→D TX tag=0x002A
flags: 0x02 (bit 1 is reserved)
data: 68 69
pre-COBS: 04 2A 00 02 68 69 | crc=0x57c7 → 04 2A 00 02 68 69 C7 57
on wire: 03 04 2A 06 02 68 69 C7 57 00
D→H ERR tag=0x002A
code: 0x0001 (EPARAM)
pre-COBS: 81 2A 00 01 00 | crc=0xf559 → 81 2A 00 01 00 59 F5
on wire: 03 81 2A 02 01 03 59 F5 00
C.5.4 TX queue full (EBUSY)
Host has pipelined tx_queue_capacity TXs and none have completed. The next TX returns ERR(EBUSY). Host waits for a TX_DONE to free a slot, then retries with a new tag.
H→D TX tag=0x002B (queue already full)
pre-COBS: 04 2B 00 00 6F 76 65 72 66 6C 6F 77 | crc=0x4824 → 04 2B 00 00 6F 76 65 72 66 6C 6F 77 24 48
on wire: 03 04 2B 01 0B 6F 76 65 72 66 6C 6F 77 24 48 00
D→H ERR tag=0x002B
code: 0x0006 (EBUSY)
pre-COBS: 81 2B 00 06 00 | crc=0x1a7a → 81 2B 00 06 00 7A 1A
on wire: 03 81 2B 02 06 03 7A 1A 00
C.5.5 TX collision (CHANNEL_BUSY) and retry
CAD detects another transmitter. TX is aborted before airtime. Host backs off (randomised 20–100 ms) and retries with a new tag.
H→D TX tag=0x0032 (data = "retry-me")
pre-COBS: 04 32 00 00 72 65 74 72 79 2D 6D 65 | crc=0x0d16 → 04 32 00 00 72 65 74 72 79 2D 6D 65 16 0D
on wire: 03 04 32 01 0B 72 65 74 72 79 2D 6D 65 16 0D 00
D→H OK tag=0x0032
pre-COBS: 80 32 00 | crc=0x9431 → 80 32 00 31 94
on wire: 03 80 32 03 31 94 00
D→H TX_DONE tag=0x0032
result: 1 (CHANNEL_BUSY), airtime_us: 0
pre-COBS: C1 32 00 01 00 00 00 00 | crc=0xee83 → C1 32 00 01 00 00 00 00 83 EE
on wire: 03 C1 32 02 01 01 01 01 03 83 EE 00
[ random backoff ]
H→D TX tag=0x0033 (same data, NEW tag)
pre-COBS: 04 33 00 00 72 65 74 72 79 2D 6D 65 | crc=0xd55f → 04 33 00 00 72 65 74 72 79 2D 6D 65 5F D5
on wire: 03 04 33 01 0B 72 65 74 72 79 2D 6D 65 5F D5 00
D→H OK tag=0x0033
pre-COBS: 80 33 00 | crc=0xa700 → 80 33 00 00 A7
on wire: 03 80 33 01 02 A7 00
D→H TX_DONE tag=0x0033
result: 0 (TRANSMITTED), airtime_us: 30976
pre-COBS: C1 33 00 00 00 79 00 00 | crc=0xba2a → C1 33 00 00 00 79 00 00 2A BA
on wire: 03 C1 33 01 01 02 79 01 03 2A BA 00
Note: the OK response for tag=0x0033 shows a COBS encoding quirk: the CRC is 0xA700, so the low byte is 0x00. COBS encodes this by inserting a 0x02 code byte that splits the run around the zero.
C.5.6 Unknown command type byte
Host sends a frame with type = 0x10 (in the reserved H→D range). Device returns ERR(EUNKNOWN_CMD) with the received tag echoed.
H→D type=0x10 tag=0x003C
type: 0x10 (reserved, not yet assigned)
payload: DE AD
pre-COBS: 10 3C 00 DE AD | crc=0x24e2 → 10 3C 00 DE AD E2 24
on wire: 03 10 3C 05 DE AD E2 24 00
D→H ERR tag=0x003C
code: 0x0005 (EUNKNOWN_CMD)
pre-COBS: 81 3C 00 05 00 | crc=0x05a3 → 81 3C 00 05 00 A3 05
on wire: 03 81 3C 02 05 03 A3 05 00
C.5.7 SET_CONFIG with truncated LoRa params
Host declares LoRa modulation but includes only 10 bytes of params instead of the required 15. Device returns ERR(ELENGTH).
H→D SET_CONFIG tag=0x0046
modulation_id: 0x01 (LoRa)
params: (10 bytes — should be 15)
pre-COBS: 03 46 00 01 00 00 00 00 00 00 00 00 00 00 | crc=0x293b → 03 46 00 01 00 00 00 00 00 00 00 00 00 00 3B 29
on wire: 03 03 46 02 01 01 01 01 01 01 01 01 01 01 03 3B 29 00
D→H ERR tag=0x0046
code: 0x0002 (ELENGTH)
pre-COBS: 81 46 00 02 00 | crc=0xb6ea → 81 46 00 02 00 EA B6
on wire: 03 81 46 02 02 03 EA B6 00
C.5.8 SET_CONFIG with frequency outside chip range
Host requests 2.45 GHz on a sub-GHz-only SX1262 (advertised range 150–960 MHz). Device returns ERR(EPARAM).
H→D SET_CONFIG tag=0x0047
modulation_id: 0x01 (LoRa)
freq_hz: 2450000000 (2.45 GHz — outside 150 MHz–960 MHz)
(other params as normal)
pre-COBS: 03 47 00 01 80 08 08 92 07 07 00 08 00 24 14 0E 00 01 00 | crc=0x4934 → 03 47 00 01 80 08 08 92 07 07 00 08 00 24 14 0E 00 01 00 34 49
on wire: 03 03 47 08 01 80 08 08 92 07 07 02 08 04 24 14 0E 02 01 03 34 49 00
D→H ERR tag=0x0047
code: 0x0001 (EPARAM)
pre-COBS: 81 47 00 01 00 | crc=0x950d → 81 47 00 01 00 0D 95
on wire: 03 81 47 02 01 03 0D 95 00
C.5.9 SET_CONFIG with unsupported modulation
Host requests FLRC (2.4 GHz-only modulation) on a sub-GHz SX1262. Capability bitmap does not advertise FLRC. Device returns ERR(EMODULATION).
H→D SET_CONFIG tag=0x0048
modulation_id: 0x04 (FLRC — not supported on SX1262)
params: [13 bytes, ignored because modulation is unsupported]
pre-COBS: 03 48 00 04 00 00 00 00 00 00 00 00 00 00 00 00 00 | crc=0x96c2 → 03 48 00 04 00 00 00 00 00 00 00 00 00 00 00 00 00 C2 96
on wire: 03 03 48 02 04 01 01 01 01 01 01 01 01 01 01 01 01 03 C2 96 00
D→H ERR tag=0x0048
code: 0x0004 (EMODULATION)
pre-COBS: 81 48 00 04 00 | crc=0xbe16 → 81 48 00 04 00 16 BE
on wire: 03 81 48 02 04 03 16 BE 00
C.5.10 Radio hardware fault during SET_CONFIG
Radio SPI fails to respond during the configuration sequence. Device returns ERR(ERADIO) synchronously, transitions to UNCONFIGURED, and places the radio in its safest reachable state.
H→D SET_CONFIG tag=0x0049 (LoRa, standard params)
pre-COBS: 03 49 00 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 | crc=0xe56a → 03 49 00 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 6A E5
on wire: 03 03 49 08 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 6A E5 00
D→H ERR tag=0x0049
code: 0x0101 (ERADIO)
pre-COBS: 81 49 00 01 01 | crc=0x2776 → 81 49 00 01 01 76 27
on wire: 03 81 49 05 01 01 76 27 00
C.6 Asynchronous events
Async events carry tag = 0x0000. They may appear interleaved anywhere in the D→H stream.
C.6.1 RX with good CRC
See C.2.6 above for an example within the continuous-RX flow.
C.6.2 RX with crc_valid=0 (corrupt packet delivered anyway)
Radio reports a packet, but PHY CRC failed. Device delivers the bytes with crc_valid = 0; the host decides whether to discard. RSSI and SNR reported but may not be meaningful.
D→H RX tag=0x0000
rssi: -1050 (-105.0 dBm — weak signal)
snr: -80 (-8.0 dB — negative SNR)
freq_err: 2200 (2.2 kHz offset)
timestamp_us: 120000000 (120.000 s)
crc_valid: 0 (FAIL — bytes may be corrupt)
packets_dropped: 0
origin: 0 (over-the-air)
data: 41 42 FF 00 CC
pre-COBS: C0 00 00 E6 FB B0 FF 98 08 00 00 00 0E 27 07 00 00 00 00 00 00 00 00 41 42 FF 00 CC | crc=0x3c04 → C0 00 00 E6 FB B0 FF 98 08 00 00 00 0E 27 07 00 00 00 00 00 00 00 00 41 42 FF 00 CC 04 3C
on wire: 02 C0 01 07 E6 FB B0 FF 98 08 01 01 04 0E 27 07 01 01 01 01 01 01 01 04 41 42 FF 04 CC 04 3C 00
C.6.3 RX following buffer overrun
Host was slow draining. Three packets arrived and were dropped because the ring was full. The next successfully-delivered RX reports packets_dropped = 3. The counter resets to 0 after this delivery.
D→H RX tag=0x0000
rssi: -730 (-73.0 dBm)
snr: 100 (+10.0 dB)
freq_err: 50 (50 Hz)
timestamp_us: 130500000
crc_valid: 1 (pass)
packets_dropped: 3 (three lost since previous delivery)
origin: 0
data: 01 02
pre-COBS: C0 00 00 26 FD 64 00 32 00 00 00 A0 45 C7 07 00 00 00 00 01 03 00 00 01 02 | crc=0x86a7 → C0 00 00 26 FD 64 00 32 00 00 00 A0 45 C7 07 00 00 00 00 01 03 00 00 01 02 A7 86
on wire: 02 C0 01 04 26 FD 64 02 32 01 01 05 A0 45 C7 07 01 01 01 03 01 03 01 05 01 02 A7 86 00
C.6.4 Malformed incoming frame triggers async ERR(EFRAME)
Host (or noise on the line) sends a frame whose CRC does not match the body. Device discards the frame and emits an async ERR(EFRAME) with tag = 0. The liveness timer is still reset — the bytes arrived, so the host is present.
D→H ERR tag=0x0000
code: 0x0102 (EFRAME)
pre-COBS: 81 00 00 02 01 | crc=0xefce → 81 00 00 02 01 CE EF
on wire: 02 81 01 05 02 01 CE EF 00
Note: the same frame type (ERR, 0x81) is used here as in synchronous error responses; the tag (0x0000) is the only discriminator. Host code distinguishes async faults from command responses by the tag value.
C.6.5 Asynchronous radio fault
Radio chip asserts an unexpected IRQ (e.g., an RxTimeout from a stale RX_START that the firmware already reset). No originating command. Device emits ERR(ERADIO) with tag = 0.
D→H ERR tag=0x0000
code: 0x0101 (ERADIO)
pre-COBS: 81 00 00 01 01 | crc=0xba9d → 81 00 00 01 01 9D BA
on wire: 02 81 01 05 01 01 9D BA 00
C.7 Multi-client scenarios (informative)
These examples assume firmware with multi-client capability bit 32 set. Single-client deployments will never see these responses.
C.7.1 Second client finds matching config (ALREADY_MATCHED)
Client A has locked LoRa SF7/BW125. Client B connects and requests identical params. Device reports ALREADY_MATCHED with owner = OTHER; Client B may proceed without changing anything.
H→D SET_CONFIG tag=0x0050 (from Client B; identical to Client A's config)
pre-COBS: 03 50 00 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 | crc=0x16cb → 03 50 00 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 CB 16
on wire: 03 03 50 08 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 CB 16 00
D→H OK tag=0x0050
result: 1 (ALREADY_MATCHED)
owner: 2 (OTHER — Client A holds the lock)
current_modulation: 0x01 (LoRa)
current_params: [SF=7, BW=125 kHz, ... same as request]
pre-COBS: 80 50 00 01 02 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 | crc=0xd834 → 80 50 00 01 02 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 34 D8
on wire: 03 80 50 0A 01 02 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 34 D8 00
C.7.2 Second client finds mismatched config (LOCKED_MISMATCH)
Client A is locked on SF7. Client B requests SF9 with different TX power. Device rejects the change; returns the actual active params so Client B can diff and decide what to do.
H→D SET_CONFIG tag=0x0051 (from Client B; sf=9, tx_power_dbm=17)
pre-COBS: 03 51 00 01 A0 27 BE 33 09 07 00 08 00 24 14 11 00 01 00 | crc=0x7ac9 → 03 51 00 01 A0 27 BE 33 09 07 00 08 00 24 14 11 00 01 00 C9 7A
on wire: 03 03 51 08 01 A0 27 BE 33 09 07 02 08 04 24 14 11 02 01 03 C9 7A 00
D→H OK tag=0x0051
result: 2 (LOCKED_MISMATCH)
owner: 2 (OTHER)
current_modulation: 0x01 (LoRa)
current_params: [SF=7, tx_power=14 — Client A's setting, unchanged]
pre-COBS: 80 51 00 02 02 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 | crc=0xedf5 → 80 51 00 02 02 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 F5 ED
on wire: 03 80 51 0A 02 02 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 F5 ED 00
C.7.3 Cross-client TX loopback (origin=1)
Client A transmits. Client B, which is also RX-active on the same device, sees the packet as an RX event with the origin = 1 flag set, distinguishing it from over-the-air traffic.
H→D TX tag=0x005A (from Client A; data = "BROADCAST")
pre-COBS: 04 5A 00 00 42 52 4F 41 44 43 41 53 54 | crc=0x7914 → 04 5A 00 00 42 52 4F 41 44 43 41 53 54 14 79
on wire: 03 04 5A 01 0C 42 52 4F 41 44 43 41 53 54 14 79 00
D→H OK tag=0x005A (to Client A only)
pre-COBS: 80 5A 00 | crc=0x16b2 → 80 5A 00 B2 16
on wire: 03 80 5A 03 B2 16 00
D→H TX_DONE tag=0x005A (to Client A only; TRANSMITTED)
pre-COBS: C1 5A 00 00 00 D8 00 00 | crc=0xa850 → C1 5A 00 00 00 D8 00 00 50 A8
on wire: 03 C1 5A 01 01 02 D8 01 03 50 A8 00
D→H RX tag=0x0000 (to Client B only; local loopback, not OTA)
rssi: 0 (n/a for local loopback)
snr: 0
freq_err: 0
timestamp_us: 180000000
crc_valid: 1 (local TX, assumed valid)
packets_dropped: 0
origin: 1 (local loopback — NOT over the air)
data: 42 52 4F 41 44 43 41 53 54 ("BROADCAST")
pre-COBS: C0 00 00 00 00 00 00 00 00 00 00 00 95 BA 0A 00 00 00 00 01 00 00 01 42 52 4F 41 44 43 41 53 54 | crc=0x00e0 → C0 00 00 00 00 00 00 00 00 00 00 00 95 BA 0A 00 00 00 00 01 00 00 01 42 52 4F 41 44 43 41 53 54 E0 00
on wire: 02 C0 01 01 01 01 01 01 01 01 01 01 04 95 BA 0A 01 01 01 02 01 01 0C 01 42 52 4F 41 44 43 41 53 54 E0 01 00
C.8 COBS edge cases
C.8.1 Frame body contains no 0x00 bytes
When the unencoded body has no zero bytes, COBS simply prepends one code byte giving the run length + 1. No internal code bytes are inserted.
H→D PING tag=0x0101
(tag bytes 0x01 0x01 — no zeros anywhere in body)
pre-COBS: 01 01 01 | crc=0xd8bc → 01 01 01 BC D8
on wire: 06 01 01 01 BC D8 00
The leading 06 means "next zero (or end of frame) is 6 bytes away".
C.8.2 Frame body contains many 0x00 bytes
RX event with all-zero metadata and a 3-byte all-zero payload. COBS must replace each zero with a run-length code byte. This is the opposite extreme from C.8.1: many short runs, many code bytes.
D→H RX tag=0x0000
rssi: 0, snr: 0, freq_err: 0
timestamp_us: 256 (0x100)
crc_valid: 1, packets_dropped: 0, origin: 0
data: 00 00 00
pre-COBS: C0 00 00 00 00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 01 00 00 00 00 00 00 | crc=0x74b4 → C0 00 00 00 00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 01 00 00 00 00 00 00 B4 74
on wire: 02 C0 01 01 01 01 01 01 01 01 01 01 02 01 01 01 01 01 01 02 01 01 01 01 01 01 03 B4 74 00
The frequent 01 bytes in the wire output are COBS code bytes pointing "next zero is 1 byte away" — each represents a single zero in the original body.
C.8.3 254-byte non-zero run triggers a 0xFF code byte
When the body contains a run of 254 or more non-zero bytes, COBS inserts an extra code byte. This is the only case where encoding adds more than a fixed constant — it adds ceil(N / 254) overhead bytes for an N-byte run.
Example: a TX command with tag=0x0101, skip_cad flag, and 253 bytes of non-zero data. Total pre-COBS body is 259 bytes with no zeros (header, flags byte, all 253 data bytes, and both CRC bytes happen to be non-zero).
Body composition (259 bytes, all non-zero):
type: 04 (1 byte)
tag: 01 01 (2 bytes)
flags: 01 (1 byte)
data: 01 02 03 ... FD (253 bytes, values 0x01..0xFD)
crc: 53 46 (2 bytes; CRC = 0x4653)
COBS output (261 bytes):
Position 0: FF ← code byte: "next zero (or end of frame)
would be 255 bytes away; bail and start
a fresh run here"
Positions 1..254: 04 01 01 01 01 02 03 ... FC (first 254 source bytes)
Position 255: 06 ← new code byte
Positions 256..260: FD 53 46 (remaining 5 source bytes:
the last data byte FD,
plus CRC 53 46)
(code says "6 bytes" = 5 data + the
virtual end-of-frame position)
Wire: 261 encoded bytes + 1 trailing 0x00 delimiter = 262 bytes total.
Buffer sizing consequence: a host encoding an N-byte frame needs at most N + ceil(N / 254) + 1 bytes of output buffer (plus the trailing delimiter). For any frame under 254 bytes, overhead is exactly 1 byte (the leading code byte) plus the trailing delimiter. At exactly 254 bytes or more, a second code byte is added. At 508+, a third. Real DongLoRa Protocol frames almost always fall in the single-overhead-byte range.
C.9 Parser resynchronization (narrative)
C.9.1 Mid-stream connection
A host process opens the transport while the device is mid-frame (for example, a previous host process was killed and a new one is starting up). The incoming byte stream looks like:
... 14 0E 02 01 03 0B 7F 00 03 05 0C 03 01 62 00 ...
|← tail of previous frame →| |← next complete frame →|
The new host discards incoming bytes until it sees a 0x00. That 0x00 closes whatever partial frame was in flight when the host started reading (which is discarded). Reading resumes with the byte immediately following: 03 05 0C 03 01 62 00 is the next complete frame, which COBS-decodes cleanly and verifies CRC. Normal operation proceeds.
No special handshake, no timeout — the self-synchronising property of COBS makes mid-stream recovery deterministic and instantaneous.
C.9.2 Single-frame corruption
The host receives three frames in rapid succession. The middle one has a bit error in transit (say, flipped by a noisy USB cable) that corrupts one of the data bytes. The host:
- Reads to the first
0x00, decodes the first frame, verifies CRC → OK, processes it. - Reads to the second
0x00, decodes the second frame, CRC check fails → discards the frame silently. Does not emit any response (it cannot determine the originating tag reliably from a corrupted frame). - Reads to the third
0x00, decodes the third frame, verifies CRC → OK, processes it.
The outstanding tag of the discarded frame remains open on the host side. The host's per-command timeout (Section 14.2) will eventually fire, at which point the host may re-issue the command with a new tag. In practice, transport-level bit errors on USB are very rare; this path is defensive rather than routine.
The device, in the symmetrical case, emits an async ERR(EFRAME) with tag 0x0000 (§C.6.4) as a diagnostic hint but does not directly signal which command was lost — the corrupted bytes are, by definition, not reliably parseable.
Appendix D — Glossary
Airtime: The duration a packet occupies the radio channel during transmission.
CAD: Channel Activity Detection. A LoRa radio primitive that briefly listens for a LoRa preamble and reports busy/clear.
COBS: Consistent Overhead Byte Stuffing. A framing technique that eliminates a chosen byte value (here, 0x00) from an encoded byte stream.
CR: Coding Rate. LoRa forward-error-correction ratio: 4/5, 4/6, 4/7, or 4/8.
FLRC: Fast Long Range Communication. A higher-throughput modulation supported by SX128x and LR2021.
LDRO: Low Data Rate Optimization. A LoRa setting required at high SF and low BW to tolerate clock drift during long symbol times.
LoRa: Long Range. A chirp-spread-spectrum modulation technique from Semtech.
LR-FHSS: Long Range Frequency-Hopping Spread Spectrum. A Semtech modulation for low-throughput satellite or very-long-range use.
Outstanding tag: A tag for which the host has sent a command but not yet received its final response.
SF: Spreading Factor. LoRa symbol-duration parameter (5–12), trading throughput for range.
Tag: The 16-bit correlation ID in the frame header, used to match responses to commands.