Skip to main content

donglora_protocol/
commands.rs

1//! Host-to-device commands (`PROTOCOL.md §6`).
2//!
3//! A `Command` is the semantic content of an H→D frame — it does not
4//! carry the tag (the tag is a frame-level concern, chosen freshly by
5//! the host for each outbound command and echoed by the device on the
6//! matching response).
7//!
8//! Encoding turns a `Command` into the payload bytes that go between a
9//! frame's header and CRC; callers then wrap the (type_id, tag, payload)
10//! tuple with `frame::encode_frame`.
11//!
12//! Parsing reverses: given a frame's `type_id` and `payload`, reconstruct
13//! the `Command`. Length mismatches and reserved-bit violations are
14//! returned as `CommandParseError` so the caller can decide whether to
15//! emit `ERR(ELENGTH)` or `ERR(EPARAM)`.
16
17use heapless::Vec as HVec;
18
19use crate::{
20    CommandEncodeError, CommandParseError, MAX_OTA_PAYLOAD, Modulation, ModulationEncodeError,
21    ModulationParseError,
22};
23
24// ── Message type identifiers ────────────────────────────────────────
25
26/// `PING` (H→D).
27pub const TYPE_PING: u8 = 0x01;
28/// `GET_INFO` (H→D).
29pub const TYPE_GET_INFO: u8 = 0x02;
30/// `SET_CONFIG` (H→D).
31pub const TYPE_SET_CONFIG: u8 = 0x03;
32/// `TX` (H→D).
33pub const TYPE_TX: u8 = 0x04;
34/// `RX_START` (H→D).
35pub const TYPE_RX_START: u8 = 0x05;
36/// `RX_STOP` (H→D).
37pub const TYPE_RX_STOP: u8 = 0x06;
38
39// ── TX flags ────────────────────────────────────────────────────────
40
41/// `TX` flag byte (`PROTOCOL.md §6.4`).
42///
43/// Bit 0 is `skip_cad`. Bits 1–7 are reserved and MUST be zero on the
44/// wire; parsing rejects any frame with reserved bits set so the caller
45/// can emit `ERR(EPARAM)` cleanly.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47#[cfg_attr(feature = "defmt", derive(defmt::Format))]
48pub struct TxFlags {
49    /// If true, skip the default CAD-before-TX and transmit immediately.
50    pub skip_cad: bool,
51}
52
53impl TxFlags {
54    pub const fn as_byte(self) -> u8 {
55        if self.skip_cad { 0b0000_0001 } else { 0 }
56    }
57
58    /// Parse the wire byte. Returns `Err(CommandParseError::ReservedBitSet)`
59    /// if any bit besides bit 0 is nonzero.
60    pub const fn from_byte(b: u8) -> Result<Self, CommandParseError> {
61        if b & !0b0000_0001 != 0 {
62            return Err(CommandParseError::ReservedBitSet);
63        }
64        Ok(Self {
65            skip_cad: b & 0b0000_0001 != 0,
66        })
67    }
68}
69
70// ── Command enum ────────────────────────────────────────────────────
71
72/// Host-to-device command.
73///
74/// `Tx.data` is owned so a parsed `Command` outlives the frame buffer it
75/// came from. This is a single memcpy over the radio packet — trivial
76/// relative to LoRa airtime, and it simplifies the firmware's statechart
77/// (which needs to carry the pending TX across state transitions).
78#[allow(clippy::large_enum_variant)]
79#[derive(Debug, Clone, PartialEq, Eq)]
80#[cfg_attr(feature = "defmt", derive(defmt::Format))]
81pub enum Command {
82    Ping,
83    GetInfo,
84    SetConfig(Modulation),
85    Tx {
86        flags: TxFlags,
87        data: HVec<u8, MAX_OTA_PAYLOAD>,
88    },
89    RxStart,
90    RxStop,
91}
92
93impl Command {
94    /// Wire-level `type_id` for this command.
95    pub const fn type_id(&self) -> u8 {
96        match self {
97            Self::Ping => TYPE_PING,
98            Self::GetInfo => TYPE_GET_INFO,
99            Self::SetConfig(_) => TYPE_SET_CONFIG,
100            Self::Tx { .. } => TYPE_TX,
101            Self::RxStart => TYPE_RX_START,
102            Self::RxStop => TYPE_RX_STOP,
103        }
104    }
105
106    /// Encode the command's payload (the bytes between the frame header
107    /// and the CRC) into `buf`. Returns the number of bytes written.
108    pub fn encode_payload(&self, buf: &mut [u8]) -> Result<usize, CommandEncodeError> {
109        match self {
110            Self::Ping | Self::GetInfo | Self::RxStart | Self::RxStop => Ok(0),
111            Self::SetConfig(m) => m.encode(buf).map_err(CommandEncodeError::from),
112            Self::Tx { flags, data } => {
113                if data.is_empty() {
114                    return Err(CommandEncodeError::EmptyTxPayload);
115                }
116                if data.len() > MAX_OTA_PAYLOAD {
117                    return Err(CommandEncodeError::PayloadTooLarge);
118                }
119                let total = 1 + data.len();
120                if buf.len() < total {
121                    return Err(CommandEncodeError::BufferTooSmall);
122                }
123                buf[0] = flags.as_byte();
124                buf[1..total].copy_from_slice(data);
125                Ok(total)
126            }
127        }
128    }
129
130    /// Parse a (`type_id`, `payload`) pair into a `Command`. The tag is
131    /// handled at the frame level and is not part of a command's
132    /// semantic content.
133    pub fn parse(type_id: u8, payload: &[u8]) -> Result<Self, CommandParseError> {
134        match type_id {
135            TYPE_PING => {
136                if !payload.is_empty() {
137                    return Err(CommandParseError::WrongLength);
138                }
139                Ok(Self::Ping)
140            }
141            TYPE_GET_INFO => {
142                if !payload.is_empty() {
143                    return Err(CommandParseError::WrongLength);
144                }
145                Ok(Self::GetInfo)
146            }
147            TYPE_SET_CONFIG => Modulation::decode(payload)
148                .map(Self::SetConfig)
149                .map_err(CommandParseError::from),
150            TYPE_TX => {
151                if payload.is_empty() {
152                    return Err(CommandParseError::WrongLength);
153                }
154                let flags = TxFlags::from_byte(payload[0])?;
155                let body = &payload[1..];
156                if body.is_empty() {
157                    return Err(CommandParseError::WrongLength);
158                }
159                if body.len() > MAX_OTA_PAYLOAD {
160                    return Err(CommandParseError::WrongLength);
161                }
162                let mut data = HVec::new();
163                data.extend_from_slice(body)
164                    .map_err(|_| CommandParseError::WrongLength)?;
165                Ok(Self::Tx { flags, data })
166            }
167            TYPE_RX_START => {
168                if !payload.is_empty() {
169                    return Err(CommandParseError::WrongLength);
170                }
171                Ok(Self::RxStart)
172            }
173            TYPE_RX_STOP => {
174                if !payload.is_empty() {
175                    return Err(CommandParseError::WrongLength);
176                }
177                Ok(Self::RxStop)
178            }
179            _ => Err(CommandParseError::UnknownType),
180        }
181    }
182}
183
184impl From<ModulationEncodeError> for CommandEncodeError {
185    fn from(e: ModulationEncodeError) -> Self {
186        match e {
187            ModulationEncodeError::BufferTooSmall => Self::BufferTooSmall,
188            ModulationEncodeError::SyncWordTooLong => Self::SyncWordTooLong,
189        }
190    }
191}
192
193impl From<ModulationParseError> for CommandParseError {
194    fn from(e: ModulationParseError) -> Self {
195        match e {
196            ModulationParseError::WrongLength { .. } | ModulationParseError::TooShort => {
197                Self::WrongLength
198            }
199            ModulationParseError::InvalidField => Self::InvalidField,
200            ModulationParseError::UnknownModulation => Self::UnknownModulation,
201        }
202    }
203}
204
205#[cfg(test)]
206#[allow(clippy::panic, clippy::unwrap_used)]
207mod tests {
208    use super::*;
209    use crate::{LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode};
210
211    fn sample_lora() -> LoRaConfig {
212        LoRaConfig {
213            freq_hz: 868_100_000,
214            sf: 7,
215            bw: LoRaBandwidth::Khz125,
216            cr: LoRaCodingRate::Cr4_5,
217            preamble_len: 8,
218            sync_word: 0x1424,
219            tx_power_dbm: 14,
220            header_mode: LoRaHeaderMode::Explicit,
221            payload_crc: true,
222            iq_invert: false,
223        }
224    }
225
226    #[test]
227    fn type_ids_match_spec() {
228        assert_eq!(TYPE_PING, 0x01);
229        assert_eq!(TYPE_GET_INFO, 0x02);
230        assert_eq!(TYPE_SET_CONFIG, 0x03);
231        assert_eq!(TYPE_TX, 0x04);
232        assert_eq!(TYPE_RX_START, 0x05);
233        assert_eq!(TYPE_RX_STOP, 0x06);
234    }
235
236    #[test]
237    fn tx_flags_roundtrip() {
238        assert_eq!(TxFlags::default().as_byte(), 0);
239        assert_eq!(TxFlags { skip_cad: true }.as_byte(), 1);
240        assert_eq!(TxFlags::from_byte(0).unwrap(), TxFlags::default());
241        assert_eq!(TxFlags::from_byte(1).unwrap(), TxFlags { skip_cad: true });
242    }
243
244    #[test]
245    fn tx_flags_reject_reserved_bits() {
246        assert!(matches!(
247            TxFlags::from_byte(0x02),
248            Err(CommandParseError::ReservedBitSet)
249        ));
250        assert!(matches!(
251            TxFlags::from_byte(0x80),
252            Err(CommandParseError::ReservedBitSet)
253        ));
254    }
255
256    #[test]
257    fn roundtrip_empty_commands() {
258        for cmd in [
259            Command::Ping,
260            Command::GetInfo,
261            Command::RxStart,
262            Command::RxStop,
263        ] {
264            let mut buf = [0u8; 4];
265            let n = cmd.encode_payload(&mut buf).unwrap();
266            assert_eq!(n, 0);
267            assert_eq!(Command::parse(cmd.type_id(), &buf[..n]).unwrap(), cmd);
268        }
269    }
270
271    #[test]
272    fn roundtrip_set_config_lora() {
273        let cmd = Command::SetConfig(Modulation::LoRa(sample_lora()));
274        let mut buf = [0u8; 32];
275        let n = cmd.encode_payload(&mut buf).unwrap();
276        // 1 (modulation_id) + 15 (LoRa wire size).
277        assert_eq!(n, 16);
278        assert_eq!(Command::parse(cmd.type_id(), &buf[..n]).unwrap(), cmd);
279    }
280
281    #[test]
282    fn roundtrip_tx_with_cad() {
283        let mut data = HVec::new();
284        data.extend_from_slice(b"Hello").unwrap();
285        let cmd = Command::Tx {
286            flags: TxFlags { skip_cad: false },
287            data,
288        };
289        let mut buf = [0u8; 8];
290        let n = cmd.encode_payload(&mut buf).unwrap();
291        assert_eq!(n, 6); // flags + 5 data bytes
292        assert_eq!(buf[0], 0x00);
293        assert_eq!(&buf[1..n], b"Hello");
294        assert_eq!(Command::parse(cmd.type_id(), &buf[..n]).unwrap(), cmd);
295    }
296
297    #[test]
298    fn roundtrip_tx_skip_cad() {
299        let mut data = HVec::new();
300        data.extend_from_slice(b"URGENT").unwrap();
301        let cmd = Command::Tx {
302            flags: TxFlags { skip_cad: true },
303            data,
304        };
305        let mut buf = [0u8; 8];
306        let n = cmd.encode_payload(&mut buf).unwrap();
307        assert_eq!(n, 7);
308        assert_eq!(buf[0], 0x01);
309        assert_eq!(&buf[1..n], b"URGENT");
310        assert_eq!(Command::parse(cmd.type_id(), &buf[..n]).unwrap(), cmd);
311    }
312
313    #[test]
314    fn tx_rejects_empty_payload() {
315        let cmd = Command::Tx {
316            flags: TxFlags::default(),
317            data: HVec::new(),
318        };
319        let mut buf = [0u8; 2];
320        assert!(matches!(
321            cmd.encode_payload(&mut buf),
322            Err(CommandEncodeError::EmptyTxPayload)
323        ));
324    }
325
326    #[test]
327    fn tx_parse_rejects_empty_payload() {
328        // Just the flags byte, no data.
329        assert!(matches!(
330            Command::parse(TYPE_TX, &[0x00]),
331            Err(CommandParseError::WrongLength)
332        ));
333    }
334
335    #[test]
336    fn ping_rejects_nonempty_payload() {
337        assert!(matches!(
338            Command::parse(TYPE_PING, &[0x00]),
339            Err(CommandParseError::WrongLength)
340        ));
341    }
342
343    #[test]
344    fn unknown_type_rejects() {
345        assert!(matches!(
346            Command::parse(0x10, &[]),
347            Err(CommandParseError::UnknownType)
348        ));
349        assert!(matches!(
350            Command::parse(0xFF, &[0xDE, 0xAD]),
351            Err(CommandParseError::UnknownType)
352        ));
353    }
354}