Skip to main content

donglora_protocol/
events.rs

1//! Device-to-host messages (`PROTOCOL.md §6.7–6.10`).
2//!
3//! Four wire types:
4//!
5//! | Hex  | Name    | Tag                    | Notes                                 |
6//! |------|---------|------------------------|---------------------------------------|
7//! | 0x80 | OK      | echoes command         | Payload shape depends on command      |
8//! | 0x81 | ERR     | command or 0x0000      | 2-byte error code                     |
9//! | 0xC0 | RX      | always 0x0000          | Rich metadata + packet bytes          |
10//! | 0xC1 | TX_DONE | echoes originating TX  | Result + airtime                      |
11//!
12//! OK is the only tricky one: its payload shape varies by what command
13//! produced it, so callers must supply the originating command's type
14//! when parsing (they know this from tag correlation). The three shapes
15//! are empty (for `PING` / `TX` / `RX_START` / `RX_STOP`), `Info` (for
16//! `GET_INFO`), and `SetConfigResult` (for `SET_CONFIG`).
17
18use heapless::Vec as HVec;
19
20use crate::{
21    DeviceMessageEncodeError, DeviceMessageParseError, ErrorCode, Info, InfoParseError,
22    MAX_OTA_PAYLOAD, Modulation, ModulationEncodeError, ModulationParseError, commands,
23};
24
25// ── D→H message type identifiers ────────────────────────────────────
26
27pub const TYPE_OK: u8 = 0x80;
28pub const TYPE_ERR: u8 = 0x81;
29pub const TYPE_RX: u8 = 0xC0;
30pub const TYPE_TX_DONE: u8 = 0xC1;
31
32// ── OK sub-shapes ───────────────────────────────────────────────────
33
34/// `SET_CONFIG` result code (`PROTOCOL.md §6.3`).
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36#[cfg_attr(feature = "defmt", derive(defmt::Format))]
37#[repr(u8)]
38pub enum SetConfigResultCode {
39    /// The request was accepted. The radio is now configured as requested.
40    Applied = 0,
41    /// Another client holds the lock; active config byte-for-byte matches.
42    AlreadyMatched = 1,
43    /// Another client holds the lock; active config differs.
44    LockedMismatch = 2,
45}
46
47impl SetConfigResultCode {
48    pub const fn as_u8(self) -> u8 {
49        self as u8
50    }
51    pub const fn from_u8(v: u8) -> Option<Self> {
52        Some(match v {
53            0 => Self::Applied,
54            1 => Self::AlreadyMatched,
55            2 => Self::LockedMismatch,
56            _ => return None,
57        })
58    }
59}
60
61/// `SET_CONFIG` lock owner (`PROTOCOL.md §6.3`).
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63#[cfg_attr(feature = "defmt", derive(defmt::Format))]
64#[repr(u8)]
65pub enum Owner {
66    /// No client currently holds the lock.
67    None = 0,
68    /// The calling client holds the lock.
69    Mine = 1,
70    /// A different client holds the lock.
71    Other = 2,
72}
73
74impl Owner {
75    pub const fn as_u8(self) -> u8 {
76        self as u8
77    }
78    pub const fn from_u8(v: u8) -> Option<Self> {
79        Some(match v {
80            0 => Self::None,
81            1 => Self::Mine,
82            2 => Self::Other,
83            _ => return None,
84        })
85    }
86}
87
88/// Payload of `OK` in response to `SET_CONFIG` (`PROTOCOL.md §6.3`).
89///
90/// `current_modulation` and `current_params` always reflect what is
91/// actually programmed into the radio at the moment the response was
92/// constructed, regardless of whether this call caused the change.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94#[cfg_attr(feature = "defmt", derive(defmt::Format))]
95pub struct SetConfigResult {
96    pub result: SetConfigResultCode,
97    pub owner: Owner,
98    pub current: Modulation,
99}
100
101impl SetConfigResult {
102    pub fn encode(&self, buf: &mut [u8]) -> Result<usize, DeviceMessageEncodeError> {
103        if buf.len() < 2 {
104            return Err(DeviceMessageEncodeError::BufferTooSmall);
105        }
106        buf[0] = self.result.as_u8();
107        buf[1] = self.owner.as_u8();
108        let rest = self.current.encode(&mut buf[2..]).map_err(|e| match e {
109            ModulationEncodeError::BufferTooSmall => DeviceMessageEncodeError::BufferTooSmall,
110            ModulationEncodeError::SyncWordTooLong => DeviceMessageEncodeError::SyncWordTooLong,
111        })?;
112        Ok(2 + rest)
113    }
114
115    pub fn decode(buf: &[u8]) -> Result<Self, DeviceMessageParseError> {
116        if buf.len() < 2 {
117            return Err(DeviceMessageParseError::TooShort);
118        }
119        let result =
120            SetConfigResultCode::from_u8(buf[0]).ok_or(DeviceMessageParseError::InvalidField)?;
121        let owner = Owner::from_u8(buf[1]).ok_or(DeviceMessageParseError::InvalidField)?;
122        let current = Modulation::decode(&buf[2..]).map_err(DeviceMessageParseError::from)?;
123        Ok(Self {
124            result,
125            owner,
126            current,
127        })
128    }
129}
130
131/// Payload shape carried by an `OK` frame.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133#[cfg_attr(feature = "defmt", derive(defmt::Format))]
134pub enum OkPayload {
135    /// For `PING`, `TX`, `RX_START`, `RX_STOP` — no body.
136    Empty,
137    /// For `GET_INFO`.
138    Info(Info),
139    /// For `SET_CONFIG`.
140    SetConfig(SetConfigResult),
141}
142
143impl OkPayload {
144    pub fn encode(&self, buf: &mut [u8]) -> Result<usize, DeviceMessageEncodeError> {
145        match self {
146            Self::Empty => Ok(0),
147            Self::Info(info) => info.encode(buf).map_err(|e| match e {
148                InfoParseError::TooShort | InfoParseError::InvalidField => {
149                    DeviceMessageEncodeError::InvalidField
150                }
151                InfoParseError::BufferTooSmall => DeviceMessageEncodeError::BufferTooSmall,
152            }),
153            Self::SetConfig(r) => r.encode(buf),
154        }
155    }
156
157    /// Parse the `OK` payload given the originating command's type byte.
158    /// Returns `Empty` for `PING`/`TX`/`RX_START`/`RX_STOP` (and verifies
159    /// that the payload is empty), `Info` for `GET_INFO`, `SetConfig` for
160    /// `SET_CONFIG`. Any other `cmd_type` is rejected.
161    pub fn parse_for(cmd_type: u8, payload: &[u8]) -> Result<Self, DeviceMessageParseError> {
162        match cmd_type {
163            commands::TYPE_PING
164            | commands::TYPE_TX
165            | commands::TYPE_RX_START
166            | commands::TYPE_RX_STOP => {
167                if !payload.is_empty() {
168                    return Err(DeviceMessageParseError::WrongLength);
169                }
170                Ok(Self::Empty)
171            }
172            commands::TYPE_GET_INFO => Info::decode(payload)
173                .map(Self::Info)
174                .map_err(DeviceMessageParseError::from),
175            commands::TYPE_SET_CONFIG => SetConfigResult::decode(payload).map(Self::SetConfig),
176            _ => Err(DeviceMessageParseError::UnknownContext),
177        }
178    }
179}
180
181// ── RX event ────────────────────────────────────────────────────────
182
183/// Packet origin — over-the-air vs. local-loopback from another client
184/// (`PROTOCOL.md §6.9`, §13.4). In single-client firmware always `Ota`.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186#[cfg_attr(feature = "defmt", derive(defmt::Format))]
187#[repr(u8)]
188pub enum RxOrigin {
189    Ota = 0,
190    LocalLoopback = 1,
191}
192
193impl RxOrigin {
194    pub const fn as_u8(self) -> u8 {
195        self as u8
196    }
197    pub const fn from_u8(v: u8) -> Option<Self> {
198        Some(match v {
199            0 => Self::Ota,
200            1 => Self::LocalLoopback,
201            _ => return None,
202        })
203    }
204}
205
206/// `RX` event payload (`PROTOCOL.md §6.9`). Metadata is the 20-byte
207/// prefix; `data` is the OTA packet body (0..=MAX_OTA_PAYLOAD bytes).
208#[derive(Debug, Clone, PartialEq, Eq)]
209#[cfg_attr(feature = "defmt", derive(defmt::Format))]
210pub struct RxPayload {
211    /// RSSI in tenths of a dBm.
212    pub rssi_tenths_dbm: i16,
213    /// SNR in tenths of a dB.
214    pub snr_tenths_db: i16,
215    /// Estimated LO offset in Hz.
216    pub freq_err_hz: i32,
217    /// Microseconds since boot, captured at RxDone IRQ.
218    pub timestamp_us: u64,
219    /// True if the radio reported CRC pass (or CRC was not configured).
220    pub crc_valid: bool,
221    /// Packets dropped since the previous delivered RX. Cleared to 0 on
222    /// every delivered event.
223    pub packets_dropped: u16,
224    pub origin: RxOrigin,
225    pub data: HVec<u8, MAX_OTA_PAYLOAD>,
226}
227
228impl RxPayload {
229    /// Fixed part of the payload preceding the variable-length `data`.
230    pub const METADATA_SIZE: usize = 20;
231
232    pub fn encode(&self, buf: &mut [u8]) -> Result<usize, DeviceMessageEncodeError> {
233        let total = Self::METADATA_SIZE + self.data.len();
234        if buf.len() < total {
235            return Err(DeviceMessageEncodeError::BufferTooSmall);
236        }
237        if self.data.len() > MAX_OTA_PAYLOAD {
238            return Err(DeviceMessageEncodeError::PayloadTooLarge);
239        }
240        buf[0..2].copy_from_slice(&self.rssi_tenths_dbm.to_le_bytes());
241        buf[2..4].copy_from_slice(&self.snr_tenths_db.to_le_bytes());
242        buf[4..8].copy_from_slice(&self.freq_err_hz.to_le_bytes());
243        buf[8..16].copy_from_slice(&self.timestamp_us.to_le_bytes());
244        buf[16] = u8::from(self.crc_valid);
245        buf[17..19].copy_from_slice(&self.packets_dropped.to_le_bytes());
246        buf[19] = self.origin.as_u8();
247        buf[20..total].copy_from_slice(&self.data);
248        Ok(total)
249    }
250
251    pub fn decode(buf: &[u8]) -> Result<Self, DeviceMessageParseError> {
252        if buf.len() < Self::METADATA_SIZE {
253            return Err(DeviceMessageParseError::TooShort);
254        }
255        let n = buf.len() - Self::METADATA_SIZE;
256        if n > MAX_OTA_PAYLOAD {
257            return Err(DeviceMessageParseError::WrongLength);
258        }
259        let rssi_tenths_dbm = i16::from_le_bytes([buf[0], buf[1]]);
260        let snr_tenths_db = i16::from_le_bytes([buf[2], buf[3]]);
261        let freq_err_hz = i32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]);
262        let timestamp_us = u64::from_le_bytes([
263            buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15],
264        ]);
265        let crc_valid = match buf[16] {
266            0 => false,
267            1 => true,
268            _ => return Err(DeviceMessageParseError::InvalidField),
269        };
270        let packets_dropped = u16::from_le_bytes([buf[17], buf[18]]);
271        let origin = RxOrigin::from_u8(buf[19]).ok_or(DeviceMessageParseError::InvalidField)?;
272        // Slice with `n` rather than `..` so the subtraction `buf.len() -
273        // METADATA_SIZE` is load-bearing: a mutant that replaces `-` with
274        // `/` yields a different `n` and therefore a shorter data slice,
275        // which round-trip tests observe.
276        let mut data = HVec::new();
277        data.extend_from_slice(&buf[Self::METADATA_SIZE..Self::METADATA_SIZE + n])
278            .map_err(|_| DeviceMessageParseError::WrongLength)?;
279        Ok(Self {
280            rssi_tenths_dbm,
281            snr_tenths_db,
282            freq_err_hz,
283            timestamp_us,
284            crc_valid,
285            packets_dropped,
286            origin,
287            data,
288        })
289    }
290}
291
292// ── TX_DONE event ───────────────────────────────────────────────────
293
294/// `TX_DONE` result code (`PROTOCOL.md §6.10`).
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296#[cfg_attr(feature = "defmt", derive(defmt::Format))]
297#[repr(u8)]
298pub enum TxResult {
299    Transmitted = 0,
300    ChannelBusy = 1,
301    Cancelled = 2,
302}
303
304impl TxResult {
305    pub const fn as_u8(self) -> u8 {
306        self as u8
307    }
308    pub const fn from_u8(v: u8) -> Option<Self> {
309        Some(match v {
310            0 => Self::Transmitted,
311            1 => Self::ChannelBusy,
312            2 => Self::Cancelled,
313            _ => return None,
314        })
315    }
316}
317
318/// `TX_DONE` payload (`PROTOCOL.md §6.10`).
319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320#[cfg_attr(feature = "defmt", derive(defmt::Format))]
321pub struct TxDonePayload {
322    pub result: TxResult,
323    /// Measured or computed on-air duration in microseconds. Always 0
324    /// when `result` is not `Transmitted`.
325    pub airtime_us: u32,
326}
327
328impl TxDonePayload {
329    pub const WIRE_SIZE: usize = 5;
330
331    pub fn encode(&self, buf: &mut [u8]) -> Result<usize, DeviceMessageEncodeError> {
332        if buf.len() < Self::WIRE_SIZE {
333            return Err(DeviceMessageEncodeError::BufferTooSmall);
334        }
335        buf[0] = self.result.as_u8();
336        buf[1..5].copy_from_slice(&self.airtime_us.to_le_bytes());
337        Ok(Self::WIRE_SIZE)
338    }
339
340    pub fn decode(buf: &[u8]) -> Result<Self, DeviceMessageParseError> {
341        if buf.len() != Self::WIRE_SIZE {
342            return Err(DeviceMessageParseError::WrongLength);
343        }
344        let result = TxResult::from_u8(buf[0]).ok_or(DeviceMessageParseError::InvalidField)?;
345        let airtime_us = u32::from_le_bytes([buf[1], buf[2], buf[3], buf[4]]);
346        Ok(Self { result, airtime_us })
347    }
348}
349
350// ── ERR payload helper ──────────────────────────────────────────────
351
352/// Encode an `ERR` payload (2 bytes of u16 LE error code).
353pub fn encode_err_payload(
354    code: ErrorCode,
355    buf: &mut [u8],
356) -> Result<usize, DeviceMessageEncodeError> {
357    if buf.len() < 2 {
358        return Err(DeviceMessageEncodeError::BufferTooSmall);
359    }
360    buf[0..2].copy_from_slice(&code.as_u16().to_le_bytes());
361    Ok(2)
362}
363
364/// Decode an `ERR` payload (2 bytes).
365pub fn decode_err_payload(buf: &[u8]) -> Result<ErrorCode, DeviceMessageParseError> {
366    if buf.len() != 2 {
367        return Err(DeviceMessageParseError::WrongLength);
368    }
369    Ok(ErrorCode::from_u16(u16::from_le_bytes([buf[0], buf[1]])))
370}
371
372// ── DeviceMessage top-level sum ────────────────────────────────────
373
374/// Any device→host message, parsed into its semantic form.
375#[derive(Debug, Clone, PartialEq, Eq)]
376#[cfg_attr(feature = "defmt", derive(defmt::Format))]
377pub enum DeviceMessage {
378    Ok(OkPayload),
379    Err(ErrorCode),
380    Rx(RxPayload),
381    TxDone(TxDonePayload),
382}
383
384impl DeviceMessage {
385    pub const fn type_id(&self) -> u8 {
386        match self {
387            Self::Ok(_) => TYPE_OK,
388            Self::Err(_) => TYPE_ERR,
389            Self::Rx(_) => TYPE_RX,
390            Self::TxDone(_) => TYPE_TX_DONE,
391        }
392    }
393
394    /// Encode the device-message payload (bytes between frame header and
395    /// CRC) into `buf`. Returns the number of bytes written.
396    pub fn encode_payload(&self, buf: &mut [u8]) -> Result<usize, DeviceMessageEncodeError> {
397        match self {
398            Self::Ok(ok) => ok.encode(buf),
399            Self::Err(code) => encode_err_payload(*code, buf),
400            Self::Rx(rx) => rx.encode(buf),
401            Self::TxDone(td) => td.encode(buf),
402        }
403    }
404
405    /// Parse a (`type_id`, `payload`) pair.
406    ///
407    /// `originating_cmd_type` MUST be supplied for `OK` frames: it is
408    /// the `type_id` of the H→D command whose tag the host observed
409    /// echoed back. For all other device-message types it is ignored
410    /// (pass `None`).
411    pub fn parse(
412        type_id: u8,
413        payload: &[u8],
414        originating_cmd_type: Option<u8>,
415    ) -> Result<Self, DeviceMessageParseError> {
416        match type_id {
417            TYPE_OK => {
418                let cmd = originating_cmd_type.ok_or(DeviceMessageParseError::MissingContext)?;
419                Ok(Self::Ok(OkPayload::parse_for(cmd, payload)?))
420            }
421            TYPE_ERR => Ok(Self::Err(decode_err_payload(payload)?)),
422            TYPE_RX => Ok(Self::Rx(RxPayload::decode(payload)?)),
423            TYPE_TX_DONE => Ok(Self::TxDone(TxDonePayload::decode(payload)?)),
424            _ => Err(DeviceMessageParseError::UnknownType),
425        }
426    }
427}
428
429// ── Error conversions ───────────────────────────────────────────────
430
431impl From<ModulationParseError> for DeviceMessageParseError {
432    fn from(e: ModulationParseError) -> Self {
433        match e {
434            ModulationParseError::WrongLength { .. } | ModulationParseError::TooShort => {
435                Self::WrongLength
436            }
437            ModulationParseError::InvalidField => Self::InvalidField,
438            ModulationParseError::UnknownModulation => Self::UnknownContext,
439        }
440    }
441}
442
443impl From<InfoParseError> for DeviceMessageParseError {
444    fn from(e: InfoParseError) -> Self {
445        match e {
446            InfoParseError::TooShort | InfoParseError::BufferTooSmall => Self::WrongLength,
447            InfoParseError::InvalidField => Self::InvalidField,
448        }
449    }
450}
451
452#[cfg(test)]
453#[allow(clippy::panic, clippy::unwrap_used)]
454mod tests {
455    use super::*;
456    use crate::{LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode};
457
458    fn sample_lora() -> LoRaConfig {
459        LoRaConfig {
460            freq_hz: 868_100_000,
461            sf: 7,
462            bw: LoRaBandwidth::Khz125,
463            cr: LoRaCodingRate::Cr4_5,
464            preamble_len: 8,
465            sync_word: 0x1424,
466            tx_power_dbm: 14,
467            header_mode: LoRaHeaderMode::Explicit,
468            payload_crc: true,
469            iq_invert: false,
470        }
471    }
472
473    #[test]
474    fn type_ids_match_spec() {
475        assert_eq!(TYPE_OK, 0x80);
476        assert_eq!(TYPE_ERR, 0x81);
477        assert_eq!(TYPE_RX, 0xC0);
478        assert_eq!(TYPE_TX_DONE, 0xC1);
479    }
480
481    #[test]
482    fn ok_empty_roundtrip_for_ping() {
483        let ok = OkPayload::Empty;
484        let mut buf = [0u8; 4];
485        let n = ok.encode(&mut buf).unwrap();
486        assert_eq!(n, 0);
487        assert_eq!(
488            OkPayload::parse_for(commands::TYPE_PING, &buf[..n]).unwrap(),
489            ok
490        );
491    }
492
493    #[test]
494    fn ok_empty_rejects_nonempty_for_tx() {
495        assert!(matches!(
496            OkPayload::parse_for(commands::TYPE_TX, &[0]),
497            Err(DeviceMessageParseError::WrongLength)
498        ));
499    }
500
501    #[test]
502    fn ok_set_config_roundtrip() {
503        let r = SetConfigResult {
504            result: SetConfigResultCode::Applied,
505            owner: Owner::Mine,
506            current: Modulation::LoRa(sample_lora()),
507        };
508        let mut buf = [0u8; 64];
509        let n = r.encode(&mut buf).unwrap();
510        // 2 (result + owner) + 1 (mod id) + 15 (LoRa).
511        assert_eq!(n, 2 + 1 + 15);
512        assert_eq!(SetConfigResult::decode(&buf[..n]).unwrap(), r);
513    }
514
515    #[test]
516    fn ok_set_config_spec_bytes_c23() {
517        // PROTOCOL.md §C.2.3 — OK in reply to SET_CONFIG (tag=0x0003).
518        // Payload is: result=0, owner=1, mod=0x01, then 15-byte LoRa params.
519        let r = SetConfigResult {
520            result: SetConfigResultCode::Applied,
521            owner: Owner::Mine,
522            current: Modulation::LoRa(sample_lora()),
523        };
524        let mut buf = [0u8; 64];
525        let n = r.encode(&mut buf).unwrap();
526        let expected: [u8; 18] = [
527            0x00, // result APPLIED
528            0x01, // owner MINE
529            0x01, // modulation_id LoRa
530            0xA0, 0x27, 0xBE, 0x33, 0x07, 0x07, 0x00, 0x08, 0x00, 0x24, 0x14, 0x0E, 0x00, 0x01,
531            0x00,
532        ];
533        assert_eq!(&buf[..n], &expected);
534    }
535
536    #[test]
537    fn err_payload_roundtrip() {
538        let mut buf = [0u8; 2];
539        let n = encode_err_payload(ErrorCode::ENotConfigured, &mut buf).unwrap();
540        assert_eq!(n, 2);
541        assert_eq!(&buf, &[0x03, 0x00]);
542        let decoded = decode_err_payload(&buf).unwrap();
543        assert_eq!(decoded, ErrorCode::ENotConfigured);
544    }
545
546    #[test]
547    fn err_payload_preserves_unknown_codes() {
548        let mut buf = [0u8; 2];
549        encode_err_payload(ErrorCode::Unknown(0xABCD), &mut buf).unwrap();
550        let decoded = decode_err_payload(&buf).unwrap();
551        assert_eq!(decoded.as_u16(), 0xABCD);
552    }
553
554    #[test]
555    fn rx_payload_roundtrip_with_data() {
556        let mut data = HVec::new();
557        data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]).unwrap();
558        let rx = RxPayload {
559            rssi_tenths_dbm: -735,
560            snr_tenths_db: 95,
561            freq_err_hz: -125,
562            timestamp_us: 42_000_000,
563            crc_valid: true,
564            packets_dropped: 0,
565            origin: RxOrigin::Ota,
566            data,
567        };
568        let mut buf = [0u8; 64];
569        let n = rx.encode(&mut buf).unwrap();
570        assert_eq!(n, 20 + 4);
571        let decoded = RxPayload::decode(&buf[..n]).unwrap();
572        assert_eq!(decoded, rx);
573    }
574
575    #[test]
576    fn rx_payload_spec_bytes_c26() {
577        // PROTOCOL.md §C.2.6 — the RX event in the continuous-RX loop.
578        let mut data = HVec::new();
579        data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]).unwrap();
580        let rx = RxPayload {
581            rssi_tenths_dbm: -735,
582            snr_tenths_db: 95,
583            freq_err_hz: -125,
584            timestamp_us: 42_000_000,
585            crc_valid: true,
586            packets_dropped: 0,
587            origin: RxOrigin::Ota,
588            data,
589        };
590        let mut buf = [0u8; 64];
591        let n = rx.encode(&mut buf).unwrap();
592        let expected: [u8; 24] = [
593            0x21, 0xFD, // rssi -735 = 0xFD21 LE
594            0x5F, 0x00, // snr 95 = 0x005F LE
595            0x83, 0xFF, 0xFF, 0xFF, // freq_err -125 LE i32
596            0x80, 0xDE, 0x80, 0x02, 0x00, 0x00, 0x00, 0x00, // timestamp 42_000_000 LE u64
597            0x01, // crc_valid
598            0x00, 0x00, // packets_dropped
599            0x00, // origin OTA
600            0x01, 0x02, 0x03, 0x04,
601        ];
602        assert_eq!(&buf[..n], &expected);
603    }
604
605    #[test]
606    fn rx_payload_rejects_invalid_crc_byte() {
607        let mut buf = [0u8; 20];
608        buf[16] = 2; // crc_valid must be 0 or 1
609        assert!(RxPayload::decode(&buf).is_err());
610    }
611
612    #[test]
613    fn tx_done_roundtrip() {
614        let td = TxDonePayload {
615            result: TxResult::Transmitted,
616            airtime_us: 30_976,
617        };
618        let mut buf = [0u8; 8];
619        let n = td.encode(&mut buf).unwrap();
620        assert_eq!(n, 5);
621        assert_eq!(TxDonePayload::decode(&buf[..n]).unwrap(), td);
622    }
623
624    #[test]
625    fn tx_done_spec_bytes_c24() {
626        // PROTOCOL.md §C.2.4 — TX_DONE for "Hello" at SF7 BW125.
627        let td = TxDonePayload {
628            result: TxResult::Transmitted,
629            airtime_us: 30_976,
630        };
631        let mut buf = [0u8; 5];
632        td.encode(&mut buf).unwrap();
633        assert_eq!(&buf, &[0x00, 0x00, 0x79, 0x00, 0x00]);
634    }
635
636    #[test]
637    fn device_message_parse_requires_ok_context() {
638        let mut buf = [0u8; 4];
639        let n = OkPayload::Empty.encode(&mut buf).unwrap();
640        assert!(matches!(
641            DeviceMessage::parse(TYPE_OK, &buf[..n], None),
642            Err(DeviceMessageParseError::MissingContext)
643        ));
644        assert!(matches!(
645            DeviceMessage::parse(TYPE_OK, &buf[..n], Some(commands::TYPE_PING)).unwrap(),
646            DeviceMessage::Ok(OkPayload::Empty)
647        ));
648    }
649
650    #[test]
651    fn device_message_unknown_type_rejects() {
652        assert!(matches!(
653            DeviceMessage::parse(0x55, &[], None),
654            Err(DeviceMessageParseError::UnknownType)
655        ));
656    }
657}