donglora-protocol 1.1.0

DongLoRa wire protocol types and COBS framing — shared between firmware and host crates
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
# 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

1. Overview
2. Frame Format
3. State Machine
4. Message Types
5. Common Data Types
6. Message Reference
7. Error Codes
8. Radio Chip ID Enum
9. Capability Bitmap
10. Modulation Parameters
11. Ordering and Interleaving
12. Flow Control and Backpressure
13. Multi-Client Operation (Future)
14. Host Behavior Requirements
15. Device Behavior Requirements
16. Version Compatibility
17. Appendix A — COBS Reference
18. Appendix B — CRC Reference
19. Appendix C — Wire Examples
20. 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

1. **Minimal.** Ten message types. No sub-protocols, no modes, no sessions.
2. **Transport-agnostic.** Runs identically over USB CDC-ACM, USB-to-UART bridges, plain UART, BLE, TCP, or any other ordered byte stream.
3. **No persistent state.** The device boots unconfigured and forgets everything on host disconnect. There is no device-side memory between sessions.
4. **No policy.** The firmware does not enforce regulatory limits, retry on failure, reorder TX, or apply profiles. The host is authoritative for all policy.
5. **Binary.** No text, no JSON, no AT commands. Every bit is accounted for.
6. **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`, asynchronous `ERR`): always `0x0000`.

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 + 20` bytes. For a device reporting `max_payload_bytes = 255`, this is 280 bytes.
- COBS adds at most `ceil(pre_cobs_length / 254) + 1` bytes of overhead.
- Plus one `0x00` delimiter 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:

1. Accumulate bytes until a `0x00` byte is received. That `0x00` is the frame delimiter.
2. COBS-decode the bytes that preceded the delimiter.
3. If the decoded length is less than 5 bytes, discard. Device MAY emit asynchronous `ERR(EFRAME)`.
4. Split the decoded bytes: `type` (1) || `tag` (2) || `payload` (variable) || `crc16` (2).
5. Compute CRC-16/CCITT-FALSE over `type || tag || payload`. If it does not match `crc16`, discard. Device MAY emit asynchronous `ERR(EFRAME)`.
6. Dispatch by `type`. Unknown `type` byte on the device side: emit `ERR(EUNKNOWN_CMD)` with the received tag. Unknown `type` on 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`) return `ERR(ENOTCONFIGURED)`.

### 3.2 CONFIGURED

- Entered when `SET_CONFIG` returns `result = APPLIED`.
- Radio parameters match the most recent successful `SET_CONFIG` payload.
- All commands accepted.
- Radio is idle (no RX, no TX) unless `RX_START` has 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:
  1. All queued-but-not-started TXs issued by that client are discarded silently. No `TX_DONE` is emitted.
  2. Any TX on the air is allowed to complete (it cannot be cleanly aborted). No `TX_DONE` is emitted.
  3. 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.
  4. In multi-client: only that client's session state is cleaned up. See Section 13.

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_len` MAY be 0 if the radio has no accessible unique identifier.
- `mcu_uid` and `radio_uid` are opaque byte strings; hosts SHOULD treat them as unique identifiers for the silicon and not interpret their internal structure.
- `capability_bitmap` tells the host which modulations and features this device supports before it attempts `SET_CONFIG`.
- `supported_sf_bitmap` and `supported_bw_bitmap` describe 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 issuing `SET_CONFIG` to 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_bitmap` bit 0 (LoRa) is set. On non-LoRa-capable devices they MAY be zero.
- `rx_queue_capacity` and `tx_queue_capacity` help the host pipeline intelligently without overrunning the device.
- `freq_min_hz` / `freq_max_hz` bracket 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_dbm` bracket the chip's settable TX power register range. The host SHOULD validate its `tx_power_dbm` against these bounds before calling `SET_CONFIG`; values outside the range yield `ERR(EPARAM)`.
- All `GET_INFO` fields 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_id` is `0x0000` and 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_params` to 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_id` is not supported on this chip. Check the capability bitmap.
- `ERR(ELENGTH)`: payload length does not match the expected size for `modulation_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_DONE` is 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_CONFIG` was issued, RX is automatically re-armed with the new parameters once reconfiguration completes; otherwise the radio is left in idle. Either way, the `OK` response is not emitted until this sequence finishes, so the host may issue TX / RX commands immediately after `OK`.

**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 (emitting `TX_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 than `max_payload_bytes`).
- `ERR(EPARAM)`: a reserved flag bit is set.
- `ERR(EBUSY)`: internal TX queue is full (already holds `tx_queue_capacity` pending TXs). Host should wait for an outstanding `TX_DONE` before 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_START` when already in continuous RX is a no-op. Response is `OK`.
- A `TX` interrupts RX, transmits, and the radio automatically returns to continuous RX afterward without a new `RX_START`. (On full-duplex chips, the interruption does not occur.)
- `SET_CONFIG` aborts continuous RX. Host must re-issue `RX_START` after 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_STOP` when not in continuous RX is a no-op. Response is `OK`.
- RX events already in the ring at the time of `RX_STOP` remain queued; the host continues to receive them as normal `RX` frames 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_us` is captured the instant the radio asserts RxDone (the end-of-packet IRQ).
- `crc_valid = 0` packets 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`, `snr` and `freq_err` may be meaningless.
- `packets_dropped` is cleared to 0 on every delivered `RX` event. It represents losses since the last successful delivery.
- `origin = 1` is only possible in multi-client mode (Section 13). In single-client it is always 0.
- `bytes` has length `N ≥ 0` and always satisfies `N ≤ max_payload_bytes`. If the radio reports a length field larger than `max_payload_bytes` (possible on a corrupted PHY header), the device MUST truncate to `max_payload_bytes` and set `crc_valid = 0`. Hosts can rely on `N ≤ max_payload_bytes` unconditionally.

---

### 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_us` is 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 when `skip_cad = 0` was set in the originating TX **and** the current modulation supports CAD (LoRa only). Any TX with `skip_cad = 1`, or any TX on a non-CAD modulation, will never produce CHANNEL_BUSY.
- **CANCELLED (2)**: TX was discarded because `SET_CONFIG` or disconnect occurred before the packet reached the air. `airtime_us = 0`.

**Notes**:
- Exactly one `TX_DONE` is emitted per `TX` that received an `OK`. A `TX` that received `ERR` produces no `TX_DONE`.
- On client disconnect, `TX_DONE` for 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 (`OK` or `ERR` with tag T_A) is emitted before the response to B (`OK` or `ERR` with 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 before `TX_DONE(T_2)`.
- This follows from the serial nature of the TX queue.

**RX and async events are unordered relative to responses**:
- `RX` events and asynchronous `ERR` events (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 `RX` event, even if the command was issued first.

**Exactly-once semantics**:
- Every command that received `OK` or `ERR` is concluded (plus the additional `TX_DONE` for `TX` that got `OK`).
- 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` (from `GET_INFO`). Additional TX attempts return `ERR(EBUSY)`. Host should wait for an outstanding `TX_DONE` before retrying.
- **RX ring**: depth = `rx_queue_capacity`. On overflow, oldest-first drop policy applies; the drop count is delivered in the next `RX` event's `packets_dropped` field.

### 12.3 Host-Side Expectations

Hosts SHOULD:
- Size their RX decoder buffer to at least `max_payload_bytes + 32` bytes.
- 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_CONFIG` from any client, while no lock is held, acquires the lock.
- The locking client may issue further `SET_CONFIG` commands freely; they always apply and return `result=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.
- 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_CONFIG` from 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_DONE` is delivered only to the originating client.
- Each successful on-air TX (`TX_DONE(result=TRANSMITTED)`) is additionally delivered to all **other** connected clients as an `RX` event with:
  - `rssi`, `snr`, `freq_err` = 0
  - `timestamp_us` = captured at TxDone IRQ
  - `crc_valid` = 1
  - `packets_dropped` = 0
  - `origin` = 1
  - `bytes` = 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:
- `owner` is always `NONE` (before `SET_CONFIG`) or `MINE` (after).
- `result` is always `APPLIED` for successful `SET_CONFIG`; `ALREADY_MATCHED` and `LOCKED_MISMATCH` never occur.
- `origin` on `RX` is 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` / `origin` fields 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. `PING` exists for this purpose.
- Never use tag `0x0000` on outbound commands.
- Track outstanding tags; do not reuse a tag until its final response has been received (`OK`, `ERR`, or `TX_DONE` as 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_INFO` once at connection to learn `max_payload_bytes`, `capability_bitmap`, queue capacities, frequency range, TX power range, and identity.
- Validate `freq_hz` and `tx_power_dbm` locally against `GET_INFO`-reported ranges before issuing `SET_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-issue `SET_CONFIG`, then `RX_START` if 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_CONFIG` parameter before writing to the radio. On validation failure the chip is not touched and an `ERR` is returned.
- Auto-enable LDRO on LoRa when `(2^SF) / BW_Hz > 16 ms`.
- Echo the command's tag on every `OK`, `ERR`, and `TX_DONE` response.
- Use tag `0x0000` for `RX` events and asynchronous `ERR` events.
- 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` / `ERR` per command, then exactly one `TX_DONE` additionally for `TX` commands that received `OK`.
- Block the `OK` response to `SET_CONFIG` until 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

```c
#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

```c
#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→D` or `D→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 | payload` bytes, then a `| crc=0x....` marker, then the full pre-encoding frame `type | tag | payload | crc_lo crc_hi`.
- **Wire bytes**: the COBS-encoded frame followed by the trailing `0x00` delimiter. 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:

1. Reads to the first `0x00`, decodes the first frame, verifies CRC → OK, processes it.
2. 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).
3. 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.