Skip to main content

donglora_client/
protocol.rs

1//! Wire protocol types and fixed-size little-endian serialization.
2//!
3//! Mirrors the firmware's `protocol.rs` using std types. Every integer is
4//! fixed-width LE.
5
6/// Maximum LoRa payload size in bytes.
7pub const MAX_PAYLOAD: usize = 256;
8
9/// RadioConfig wire size (fixed).
10pub const RADIO_CONFIG_SIZE: usize = 13;
11
12/// Sentinel value for `tx_power_dbm`: use the board's maximum TX power.
13pub const TX_POWER_MAX: i8 = i8::MIN; // -128 on the wire
14
15/// Sentinel value for `preamble_len`: use the firmware default (16 symbols).
16pub const PREAMBLE_DEFAULT: u16 = 0;
17
18// ── Tag constants ──────────────────────────────────────────────────
19
20/// Command tag for SetConfig.
21pub const CMD_TAG_SET_CONFIG: u8 = 2;
22/// Command tag for StartRx.
23pub const CMD_TAG_START_RX: u8 = 3;
24/// Command tag for StopRx.
25pub const CMD_TAG_STOP_RX: u8 = 4;
26
27/// Response tag for RxPacket.
28pub const RESP_TAG_RX_PACKET: u8 = 2;
29/// Response tag for Ok.
30pub const RESP_TAG_OK: u8 = 4;
31/// Response tag for Error.
32pub const RESP_TAG_ERROR: u8 = 5;
33
34/// Error code for InvalidConfig.
35pub const ERROR_INVALID_CONFIG: u8 = 0;
36
37// ── Bandwidth ──────────────────────────────────────────────────────
38
39/// LoRa signal bandwidth.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41#[repr(u8)]
42pub enum Bandwidth {
43    Khz7 = 0,
44    Khz10 = 1,
45    Khz15 = 2,
46    Khz20 = 3,
47    Khz31 = 4,
48    Khz41 = 5,
49    Khz62 = 6,
50    Khz125 = 7,
51    Khz250 = 8,
52    Khz500 = 9,
53}
54
55impl Bandwidth {
56    /// Convert from raw byte. Returns `None` for invalid values.
57    pub fn from_u8(v: u8) -> Option<Self> {
58        match v {
59            0 => Some(Self::Khz7),
60            1 => Some(Self::Khz10),
61            2 => Some(Self::Khz15),
62            3 => Some(Self::Khz20),
63            4 => Some(Self::Khz31),
64            5 => Some(Self::Khz41),
65            6 => Some(Self::Khz62),
66            7 => Some(Self::Khz125),
67            8 => Some(Self::Khz250),
68            9 => Some(Self::Khz500),
69            _ => None,
70        }
71    }
72}
73
74// ── RadioConfig ────────────────────────────────────────────────────
75
76/// Complete LoRa radio configuration.
77///
78/// Wire layout (13 bytes, all little-endian):
79/// ```text
80/// [freq_hz:4] [bw:1] [sf:1] [cr:1] [sync_word:2] [tx_power_dbm:1] [preamble_len:2] [cad:1]
81/// ```
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct RadioConfig {
84    /// Frequency in Hz (150_000_000 - 960_000_000 for SX1262).
85    pub freq_hz: u32,
86    pub bw: Bandwidth,
87    /// Spreading factor (5-12).
88    pub sf: u8,
89    /// Coding rate denominator (5-8). E.g. 5 = CR 4/5, 8 = CR 4/8.
90    pub cr: u8,
91    pub sync_word: u16,
92    /// Transmit power in dBm. Set to [`TX_POWER_MAX`] (-128) for the board's maximum.
93    pub tx_power_dbm: i8,
94    /// Preamble length in symbols. Set to [`PREAMBLE_DEFAULT`] (0) for firmware default (16).
95    pub preamble_len: u16,
96    /// Channel Activity Detection (listen-before-talk). 0 = disabled, non-zero = enabled.
97    pub cad: u8,
98}
99
100impl RadioConfig {
101    /// Serialize to fixed-size LE bytes.
102    pub fn to_bytes(&self) -> [u8; RADIO_CONFIG_SIZE] {
103        let mut buf = [0u8; RADIO_CONFIG_SIZE];
104        buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
105        buf[4] = self.bw as u8;
106        buf[5] = self.sf;
107        buf[6] = self.cr;
108        buf[7..9].copy_from_slice(&self.sync_word.to_le_bytes());
109        buf[9] = self.tx_power_dbm as u8;
110        buf[10..12].copy_from_slice(&self.preamble_len.to_le_bytes());
111        buf[12] = self.cad;
112        buf
113    }
114
115    /// Deserialize from fixed-size LE bytes. Returns `None` if too short or invalid.
116    pub fn from_bytes(buf: &[u8]) -> Option<Self> {
117        if buf.len() < RADIO_CONFIG_SIZE {
118            return None;
119        }
120        Some(Self {
121            freq_hz: u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]),
122            bw: Bandwidth::from_u8(buf[4])?,
123            sf: buf[5],
124            cr: buf[6],
125            sync_word: u16::from_le_bytes([buf[7], buf[8]]),
126            tx_power_dbm: buf[9] as i8,
127            preamble_len: u16::from_le_bytes([buf[10], buf[11]]),
128            cad: buf[12],
129        })
130    }
131}
132
133/// Default: 915 MHz, 125 kHz BW, SF7, CR 4/5, sync 0x1424, max power, default preamble, CAD on.
134impl Default for RadioConfig {
135    fn default() -> Self {
136        Self {
137            freq_hz: 915_000_000,
138            bw: Bandwidth::Khz125,
139            sf: 7,
140            cr: 5,
141            sync_word: 0x1424,
142            tx_power_dbm: TX_POWER_MAX,
143            preamble_len: PREAMBLE_DEFAULT,
144            cad: 1,
145        }
146    }
147}
148
149// ── Command ────────────────────────────────────────────────────────
150
151/// Host → firmware commands.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub enum Command {
154    Ping,
155    GetConfig,
156    SetConfig(RadioConfig),
157    StartRx,
158    StopRx,
159    Transmit { config: Option<RadioConfig>, payload: Vec<u8> },
160    DisplayOn,
161    DisplayOff,
162    GetMac,
163}
164
165impl Command {
166    /// Returns the wire tag byte for this command.
167    pub fn tag(&self) -> u8 {
168        match self {
169            Self::Ping => 0,
170            Self::GetConfig => 1,
171            Self::SetConfig(_) => 2,
172            Self::StartRx => 3,
173            Self::StopRx => 4,
174            Self::Transmit { .. } => 5,
175            Self::DisplayOn => 6,
176            Self::DisplayOff => 7,
177            Self::GetMac => 8,
178        }
179    }
180
181    /// Serialize to fixed-size LE bytes.
182    pub fn to_bytes(&self) -> Vec<u8> {
183        match self {
184            Self::Ping => vec![0],
185            Self::GetConfig => vec![1],
186            Self::SetConfig(cfg) => {
187                let mut out = vec![2];
188                out.extend_from_slice(&cfg.to_bytes());
189                out
190            }
191            Self::StartRx => vec![3],
192            Self::StopRx => vec![4],
193            Self::Transmit { config, payload } => {
194                let mut out = vec![5];
195                match config {
196                    None => out.push(0),
197                    Some(cfg) => {
198                        out.push(1);
199                        out.extend_from_slice(&cfg.to_bytes());
200                    }
201                }
202                out.extend_from_slice(&(payload.len() as u16).to_le_bytes());
203                out.extend_from_slice(payload);
204                out
205            }
206            Self::DisplayOn => vec![6],
207            Self::DisplayOff => vec![7],
208            Self::GetMac => vec![8],
209        }
210    }
211
212    /// Deserialize from a decoded frame. Returns `None` on malformed input.
213    pub fn from_bytes(buf: &[u8]) -> Option<Self> {
214        let tag = *buf.first()?;
215        let rest = &buf[1..];
216        match tag {
217            0 => Some(Self::Ping),
218            1 => Some(Self::GetConfig),
219            2 => Some(Self::SetConfig(RadioConfig::from_bytes(rest)?)),
220            3 => Some(Self::StartRx),
221            4 => Some(Self::StopRx),
222            5 => {
223                if rest.is_empty() {
224                    return None;
225                }
226                let (config, pos) = if rest[0] == 0 {
227                    (None, 1)
228                } else if rest[0] == 1 && rest.len() > RADIO_CONFIG_SIZE {
229                    (Some(RadioConfig::from_bytes(&rest[1..])?), 1 + RADIO_CONFIG_SIZE)
230                } else {
231                    return None;
232                };
233                if rest.len() < pos + 2 {
234                    return None;
235                }
236                let len = u16::from_le_bytes([rest[pos], rest[pos + 1]]) as usize;
237                let data_start = pos + 2;
238                if rest.len() < data_start + len || len > MAX_PAYLOAD {
239                    return None;
240                }
241                Some(Self::Transmit { config, payload: rest[data_start..data_start + len].to_vec() })
242            }
243            6 => Some(Self::DisplayOn),
244            7 => Some(Self::DisplayOff),
245            8 => Some(Self::GetMac),
246            _ => None,
247        }
248    }
249}
250
251// ── ErrorCode ──────────────────────────────────────────────────────
252
253/// Error codes reported by the firmware.
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255#[repr(u8)]
256pub enum ErrorCode {
257    InvalidConfig = 0,
258    RadioBusy = 1,
259    TxTimeout = 2,
260    CrcError = 3,
261    NotConfigured = 4,
262    NoDisplay = 5,
263}
264
265impl ErrorCode {
266    /// Convert from raw byte. Returns `None` for unknown codes.
267    pub fn from_u8(v: u8) -> Option<Self> {
268        match v {
269            0 => Some(Self::InvalidConfig),
270            1 => Some(Self::RadioBusy),
271            2 => Some(Self::TxTimeout),
272            3 => Some(Self::CrcError),
273            4 => Some(Self::NotConfigured),
274            5 => Some(Self::NoDisplay),
275            _ => None,
276        }
277    }
278}
279
280impl std::error::Error for ErrorCode {}
281
282impl std::fmt::Display for ErrorCode {
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        match self {
285            Self::InvalidConfig => write!(f, "InvalidConfig"),
286            Self::RadioBusy => write!(f, "RadioBusy"),
287            Self::TxTimeout => write!(f, "TxTimeout"),
288            Self::CrcError => write!(f, "CrcError"),
289            Self::NotConfigured => write!(f, "NotConfigured"),
290            Self::NoDisplay => write!(f, "NoDisplay"),
291        }
292    }
293}
294
295// ── Response ───────────────────────────────────────────────────────
296
297/// Firmware → host responses.
298#[derive(Debug, Clone, PartialEq, Eq)]
299pub enum Response {
300    Pong,
301    Config(RadioConfig),
302    RxPacket { rssi: i16, snr: i16, payload: Vec<u8> },
303    TxDone,
304    Ok,
305    Error(ErrorCode),
306    MacAddress([u8; 6]),
307}
308
309impl Response {
310    /// Returns the wire tag byte for this response.
311    pub fn tag(&self) -> u8 {
312        match self {
313            Self::Pong => 0,
314            Self::Config(_) => 1,
315            Self::RxPacket { .. } => 2,
316            Self::TxDone => 3,
317            Self::Ok => 4,
318            Self::Error(_) => 5,
319            Self::MacAddress(_) => 6,
320        }
321    }
322
323    /// Whether this is an unsolicited RxPacket.
324    pub fn is_rx_packet(&self) -> bool {
325        matches!(self, Self::RxPacket { .. })
326    }
327
328    /// Serialize to fixed-size LE bytes.
329    pub fn to_bytes(&self) -> Vec<u8> {
330        match self {
331            Self::Pong => vec![0],
332            Self::Config(cfg) => {
333                let mut out = vec![1];
334                out.extend_from_slice(&cfg.to_bytes());
335                out
336            }
337            Self::RxPacket { rssi, snr, payload } => {
338                let mut out = vec![2];
339                out.extend_from_slice(&rssi.to_le_bytes());
340                out.extend_from_slice(&snr.to_le_bytes());
341                out.extend_from_slice(&(payload.len() as u16).to_le_bytes());
342                out.extend_from_slice(payload);
343                out
344            }
345            Self::TxDone => vec![3],
346            Self::Ok => vec![4],
347            Self::Error(code) => vec![5, *code as u8],
348            Self::MacAddress(mac) => {
349                let mut out = vec![6];
350                out.extend_from_slice(mac);
351                out
352            }
353        }
354    }
355
356    /// Deserialize from a decoded frame. Returns `None` on malformed input.
357    pub fn from_bytes(buf: &[u8]) -> Option<Self> {
358        let tag = *buf.first()?;
359        let rest = &buf[1..];
360        match tag {
361            0 => Some(Self::Pong),
362            1 => Some(Self::Config(RadioConfig::from_bytes(rest)?)),
363            2 => {
364                if rest.len() < 6 {
365                    return None;
366                }
367                let rssi = i16::from_le_bytes([rest[0], rest[1]]);
368                let snr = i16::from_le_bytes([rest[2], rest[3]]);
369                let len = u16::from_le_bytes([rest[4], rest[5]]) as usize;
370                if rest.len() < 6 + len || len > MAX_PAYLOAD {
371                    return None;
372                }
373                Some(Self::RxPacket { rssi, snr, payload: rest[6..6 + len].to_vec() })
374            }
375            3 => Some(Self::TxDone),
376            4 => Some(Self::Ok),
377            5 => Some(Self::Error(ErrorCode::from_u8(*rest.first()?)?)),
378            6 => {
379                if rest.len() < 6 {
380                    return None;
381                }
382                let mut mac = [0u8; 6];
383                mac.copy_from_slice(&rest[..6]);
384                Some(Self::MacAddress(mac))
385            }
386            _ => None,
387        }
388    }
389}
390
391// ── Tests ──────────────────────────────────────────────────────────
392
393#[cfg(test)]
394#[allow(clippy::unwrap_used)]
395mod tests {
396    use super::*;
397
398    fn make_config() -> RadioConfig {
399        RadioConfig {
400            freq_hz: 915_000_000,
401            bw: Bandwidth::Khz125,
402            sf: 7,
403            cr: 5,
404            sync_word: 0x3444,
405            tx_power_dbm: 22,
406            preamble_len: 16,
407            cad: 1,
408        }
409    }
410
411    // ── RadioConfig ────────────────────────────────────────────────
412
413    #[test]
414    fn radio_config_roundtrip() {
415        let cfg = make_config();
416        let bytes = cfg.to_bytes();
417        assert_eq!(bytes.len(), RADIO_CONFIG_SIZE);
418        assert_eq!(RadioConfig::from_bytes(&bytes), Some(cfg));
419    }
420
421    #[test]
422    fn radio_config_default_roundtrip() {
423        let cfg = RadioConfig::default();
424        let bytes = cfg.to_bytes();
425        assert_eq!(RadioConfig::from_bytes(&bytes), Some(cfg));
426    }
427
428    #[test]
429    fn radio_config_all_bandwidths() {
430        for bw_val in 0u8..=9 {
431            let bw = Bandwidth::from_u8(bw_val);
432            assert!(bw.is_some(), "bandwidth {bw_val} should be valid");
433            let cfg = RadioConfig { bw: bw.unwrap(), ..make_config() };
434            let bytes = cfg.to_bytes();
435            assert_eq!(RadioConfig::from_bytes(&bytes), Some(cfg));
436        }
437    }
438
439    #[test]
440    fn radio_config_invalid_bandwidth() {
441        let mut buf = make_config().to_bytes();
442        buf[4] = 255;
443        assert!(RadioConfig::from_bytes(&buf).is_none());
444    }
445
446    #[test]
447    fn radio_config_negative_power() {
448        let cfg = RadioConfig { tx_power_dbm: TX_POWER_MAX, ..make_config() };
449        let bytes = cfg.to_bytes();
450        assert_eq!(RadioConfig::from_bytes(&bytes), Some(cfg));
451    }
452
453    #[test]
454    fn radio_config_short_buffer() {
455        assert!(RadioConfig::from_bytes(&[0u8; 12]).is_none());
456        assert!(RadioConfig::from_bytes(&[]).is_none());
457    }
458
459    // ── Command ────────────────────────────────────────────────────
460
461    #[test]
462    fn command_simple_roundtrips() {
463        for cmd in [
464            Command::Ping,
465            Command::GetConfig,
466            Command::StartRx,
467            Command::StopRx,
468            Command::DisplayOn,
469            Command::DisplayOff,
470            Command::GetMac,
471        ] {
472            let bytes = cmd.to_bytes();
473            assert_eq!(Command::from_bytes(&bytes), Some(cmd));
474        }
475    }
476
477    #[test]
478    fn command_set_config_roundtrip() {
479        let cmd = Command::SetConfig(make_config());
480        let bytes = cmd.to_bytes();
481        assert_eq!(Command::from_bytes(&bytes), Some(cmd));
482    }
483
484    #[test]
485    fn command_transmit_no_config_roundtrip() {
486        let cmd = Command::Transmit { config: None, payload: b"hello".to_vec() };
487        let bytes = cmd.to_bytes();
488        assert_eq!(Command::from_bytes(&bytes), Some(cmd));
489    }
490
491    #[test]
492    fn command_transmit_with_config_roundtrip() {
493        let cmd = Command::Transmit { config: Some(make_config()), payload: b"test".to_vec() };
494        let bytes = cmd.to_bytes();
495        assert_eq!(Command::from_bytes(&bytes), Some(cmd));
496    }
497
498    #[test]
499    fn command_transmit_empty_payload() {
500        let cmd = Command::Transmit { config: None, payload: vec![] };
501        let bytes = cmd.to_bytes();
502        assert_eq!(Command::from_bytes(&bytes), Some(cmd));
503    }
504
505    #[test]
506    fn command_transmit_truncated() {
507        assert!(Command::from_bytes(&[5]).is_none()); // tag only
508        assert!(Command::from_bytes(&[5, 1]).is_none()); // has_config=1, no config
509        assert!(Command::from_bytes(&[5, 0]).is_none()); // has_config=0, no length
510    }
511
512    #[test]
513    fn command_invalid_tag() {
514        assert!(Command::from_bytes(&[9]).is_none());
515        assert!(Command::from_bytes(&[255]).is_none());
516    }
517
518    #[test]
519    fn command_empty_buffer() {
520        assert!(Command::from_bytes(&[]).is_none());
521    }
522
523    #[test]
524    fn command_tags() {
525        assert_eq!(Command::Ping.tag(), 0);
526        assert_eq!(Command::GetConfig.tag(), 1);
527        assert_eq!(Command::SetConfig(make_config()).tag(), 2);
528        assert_eq!(Command::StartRx.tag(), 3);
529        assert_eq!(Command::StopRx.tag(), 4);
530        assert_eq!(Command::Transmit { config: None, payload: vec![] }.tag(), 5);
531        assert_eq!(Command::DisplayOn.tag(), 6);
532        assert_eq!(Command::DisplayOff.tag(), 7);
533        assert_eq!(Command::GetMac.tag(), 8);
534    }
535
536    // ── Response ───────────────────────────────────────────────────
537
538    #[test]
539    fn response_simple_roundtrips() {
540        for resp in [Response::Pong, Response::TxDone, Response::Ok] {
541            let bytes = resp.to_bytes();
542            assert_eq!(Response::from_bytes(&bytes), Some(resp));
543        }
544    }
545
546    #[test]
547    fn response_config_roundtrip() {
548        let resp = Response::Config(make_config());
549        let bytes = resp.to_bytes();
550        assert_eq!(Response::from_bytes(&bytes), Some(resp));
551    }
552
553    #[test]
554    fn response_rx_packet_roundtrip() {
555        let resp = Response::RxPacket { rssi: -80, snr: 10, payload: b"data".to_vec() };
556        let bytes = resp.to_bytes();
557        assert_eq!(Response::from_bytes(&bytes), Some(resp));
558    }
559
560    #[test]
561    fn response_rx_packet_empty_payload() {
562        let resp = Response::RxPacket { rssi: -120, snr: -5, payload: vec![] };
563        let bytes = resp.to_bytes();
564        assert_eq!(Response::from_bytes(&bytes), Some(resp));
565    }
566
567    #[test]
568    fn response_error_codes() {
569        for code in [
570            ErrorCode::InvalidConfig,
571            ErrorCode::RadioBusy,
572            ErrorCode::TxTimeout,
573            ErrorCode::CrcError,
574            ErrorCode::NotConfigured,
575            ErrorCode::NoDisplay,
576        ] {
577            let resp = Response::Error(code);
578            let bytes = resp.to_bytes();
579            assert_eq!(Response::from_bytes(&bytes), Some(resp));
580        }
581    }
582
583    #[test]
584    fn response_mac_address_roundtrip() {
585        let resp = Response::MacAddress([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
586        let bytes = resp.to_bytes();
587        assert_eq!(Response::from_bytes(&bytes), Some(resp));
588    }
589
590    #[test]
591    fn response_tags() {
592        assert_eq!(Response::Pong.tag(), 0);
593        assert_eq!(Response::Config(make_config()).tag(), 1);
594        assert_eq!(Response::RxPacket { rssi: 0, snr: 0, payload: vec![] }.tag(), 2);
595        assert_eq!(Response::TxDone.tag(), 3);
596        assert_eq!(Response::Ok.tag(), 4);
597        assert_eq!(Response::Error(ErrorCode::RadioBusy).tag(), 5);
598        assert_eq!(Response::MacAddress([0; 6]).tag(), 6);
599    }
600
601    #[test]
602    fn response_is_rx_packet() {
603        assert!(Response::RxPacket { rssi: 0, snr: 0, payload: vec![] }.is_rx_packet());
604        assert!(!Response::Ok.is_rx_packet());
605        assert!(!Response::Pong.is_rx_packet());
606    }
607
608    #[test]
609    fn response_invalid_tag() {
610        assert!(Response::from_bytes(&[7]).is_none());
611        assert!(Response::from_bytes(&[255]).is_none());
612    }
613
614    #[test]
615    fn response_truncated() {
616        assert!(Response::from_bytes(&[]).is_none());
617        assert!(Response::from_bytes(&[5]).is_none()); // Error with no code
618        assert!(Response::from_bytes(&[2, 0, 0]).is_none()); // RxPacket too short
619        assert!(Response::from_bytes(&[6, 0, 0, 0]).is_none()); // MacAddress too short
620    }
621
622    // ── ErrorCode ──────────────────────────────────────────────────
623
624    #[test]
625    fn error_code_from_u8() {
626        for v in 0..=5 {
627            assert!(ErrorCode::from_u8(v).is_some());
628        }
629        assert!(ErrorCode::from_u8(6).is_none());
630        assert!(ErrorCode::from_u8(255).is_none());
631    }
632
633    #[test]
634    fn error_code_display() {
635        assert_eq!(ErrorCode::InvalidConfig.to_string(), "InvalidConfig");
636        assert_eq!(ErrorCode::RadioBusy.to_string(), "RadioBusy");
637    }
638
639    // ── Cross-compatibility with firmware encoding ─────────────────
640
641    #[test]
642    fn firmware_worked_example() {
643        // From PROTOCOL.md: 915 MHz, 125 kHz BW, SF7, CR 4/5, sync 0x1424, max power
644        let cfg = RadioConfig {
645            freq_hz: 915_000_000,
646            bw: Bandwidth::Khz125,
647            sf: 7,
648            cr: 5,
649            sync_word: 0x1424,
650            tx_power_dbm: TX_POWER_MAX,
651            preamble_len: PREAMBLE_DEFAULT,
652            cad: 1,
653        };
654        let cmd = Command::SetConfig(cfg);
655        let bytes = cmd.to_bytes();
656        // Expected: 02 C0 CA 89 36 07 07 05 24 14 80 00 00 01
657        assert_eq!(bytes, [0x02, 0xC0, 0xCA, 0x89, 0x36, 0x07, 0x07, 0x05, 0x24, 0x14, 0x80, 0x00, 0x00, 0x01]);
658    }
659}