Skip to main content

donglora_protocol/
lib.rs

1//! Wire protocol types and fixed-size little-endian serialization.
2//!
3//! Every integer is fixed-width LE. No varints, no zigzag.
4//! See `docs/PROTOCOL.md` for the complete specification.
5
6#![no_std]
7
8pub mod framing;
9
10use heapless::Vec;
11
12/// Maximum LoRa payload size in bytes.
13pub const MAX_PAYLOAD: usize = 256;
14
15/// RadioConfig wire size (fixed).
16pub const RADIO_CONFIG_SIZE: usize = 13;
17
18/// Sentinel value for `tx_power_dbm`: use the board's maximum TX power.
19pub const TX_POWER_MAX: i8 = i8::MIN; // -128 on the wire
20
21/// Sentinel value for `preamble_len`: use the firmware default (16 symbols).
22pub const PREAMBLE_DEFAULT: u16 = 0;
23
24/// LoRa signal bandwidth.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26#[cfg_attr(feature = "defmt", derive(defmt::Format))]
27#[repr(u8)]
28pub enum Bandwidth {
29    Khz7 = 0,
30    Khz10 = 1,
31    Khz15 = 2,
32    Khz20 = 3,
33    Khz31 = 4,
34    Khz41 = 5,
35    Khz62 = 6,
36    Khz125 = 7,
37    Khz250 = 8,
38    Khz500 = 9,
39}
40
41impl Bandwidth {
42    fn from_u8(v: u8) -> Option<Self> {
43        match v {
44            0 => Some(Self::Khz7),
45            1 => Some(Self::Khz10),
46            2 => Some(Self::Khz15),
47            3 => Some(Self::Khz20),
48            4 => Some(Self::Khz31),
49            5 => Some(Self::Khz41),
50            6 => Some(Self::Khz62),
51            7 => Some(Self::Khz125),
52            8 => Some(Self::Khz250),
53            9 => Some(Self::Khz500),
54            _ => None,
55        }
56    }
57}
58
59/// Complete LoRa radio configuration.
60///
61/// Wire layout (13 bytes, all little-endian):
62/// ```text
63/// [freq_hz:4] [bw:1] [sf:1] [cr:1] [sync_word:2] [tx_power_dbm:1] [preamble_len:2] [cad:1]
64/// ```
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66#[cfg_attr(feature = "defmt", derive(defmt::Format))]
67pub struct RadioConfig {
68    /// Frequency in Hz (150_000_000 - 960_000_000 for SX1262).
69    pub freq_hz: u32,
70    pub bw: Bandwidth,
71    /// Spreading factor (5-12).
72    pub sf: u8,
73    /// Coding rate denominator (5-8). E.g. 5 = CR 4/5, 8 = CR 4/8.
74    pub cr: u8,
75    pub sync_word: u16,
76    /// Transmit power in dBm. Set to [`TX_POWER_MAX`] (-128) for the
77    /// board's maximum.
78    pub tx_power_dbm: i8,
79    /// Preamble length in symbols. Set to [`PREAMBLE_DEFAULT`] (0) for
80    /// the firmware default (16 symbols). Valid range: 6–65535.
81    pub preamble_len: u16,
82    /// Channel Activity Detection (listen-before-talk) before TX.
83    /// 0 = disabled, non-zero = enabled. Default: 1 (enabled).
84    pub cad: u8,
85}
86
87impl RadioConfig {
88    /// Validate fields against hardware limits.
89    pub fn validate(&self, power_range: (i8, i8)) -> Result<(), &'static str> {
90        if !(150_000_000..=960_000_000).contains(&self.freq_hz) {
91            return Err("frequency out of range (150-960 MHz)");
92        }
93        if !(5..=12).contains(&self.sf) {
94            return Err("spreading factor out of range (5-12)");
95        }
96        if !(5..=8).contains(&self.cr) {
97            return Err("coding rate out of range (5-8)");
98        }
99        if self.tx_power_dbm != TX_POWER_MAX
100            && !(power_range.0..=power_range.1).contains(&self.tx_power_dbm)
101        {
102            return Err("TX power out of range for this board");
103        }
104        if self.preamble_len != PREAMBLE_DEFAULT && self.preamble_len < 6 {
105            return Err("preamble length too short (min 6)");
106        }
107        Ok(())
108    }
109
110    /// Resolve sentinel values to concrete defaults.
111    pub fn resolve(mut self, power_range: (i8, i8)) -> Self {
112        if self.tx_power_dbm == TX_POWER_MAX {
113            self.tx_power_dbm = power_range.1;
114        }
115        if self.preamble_len == PREAMBLE_DEFAULT {
116            self.preamble_len = 16;
117        }
118        self
119    }
120
121    /// Whether CAD (listen-before-talk) is enabled.
122    pub fn cad_enabled(&self) -> bool {
123        self.cad != 0
124    }
125
126    /// Serialize to fixed-size LE bytes. Returns number of bytes written (always 13).
127    pub fn write_to(self, buf: &mut [u8]) -> usize {
128        buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
129        buf[4] = self.bw as u8;
130        buf[5] = self.sf;
131        buf[6] = self.cr;
132        buf[7..9].copy_from_slice(&self.sync_word.to_le_bytes());
133        buf[9] = self.tx_power_dbm as u8;
134        buf[10..12].copy_from_slice(&self.preamble_len.to_le_bytes());
135        buf[12] = self.cad;
136        RADIO_CONFIG_SIZE
137    }
138
139    /// Deserialize from fixed-size LE bytes.
140    pub fn from_bytes(buf: &[u8]) -> Option<Self> {
141        if buf.len() < RADIO_CONFIG_SIZE {
142            return None;
143        }
144        Some(Self {
145            freq_hz: u32::from_le_bytes(buf[0..4].try_into().ok()?),
146            bw: Bandwidth::from_u8(buf[4])?,
147            sf: buf[5],
148            cr: buf[6],
149            sync_word: u16::from_le_bytes(buf[7..9].try_into().ok()?),
150            tx_power_dbm: buf[9] as i8,
151            preamble_len: u16::from_le_bytes(buf[10..12].try_into().ok()?),
152            cad: buf[12],
153        })
154    }
155}
156
157/// Host → firmware commands.
158#[allow(clippy::large_enum_variant)]
159#[derive(Debug, Clone, PartialEq)]
160pub enum Command {
161    Ping,
162    GetConfig,
163    SetConfig(RadioConfig),
164    StartRx,
165    StopRx,
166    Transmit {
167        config: Option<RadioConfig>,
168        payload: Vec<u8, MAX_PAYLOAD>,
169    },
170    DisplayOn,
171    DisplayOff,
172    GetMac,
173}
174
175impl Command {
176    /// Deserialize from a COBS-decoded frame.
177    pub fn from_bytes(buf: &[u8]) -> Option<Self> {
178        let tag = *buf.first()?;
179        let rest = &buf[1..];
180        match tag {
181            0 => Some(Self::Ping),
182            1 => Some(Self::GetConfig),
183            2 => Some(Self::SetConfig(RadioConfig::from_bytes(rest)?)),
184            3 => Some(Self::StartRx),
185            4 => Some(Self::StopRx),
186            5 => {
187                // Transmit: has_config:u8 + [RadioConfig if 1] + len:u16 LE + payload
188                if rest.is_empty() {
189                    return None;
190                }
191                let (config, pos) = if rest[0] == 0 {
192                    (None, 1)
193                } else if rest[0] == 1 && rest.len() > RADIO_CONFIG_SIZE {
194                    (
195                        Some(RadioConfig::from_bytes(&rest[1..])?),
196                        1 + RADIO_CONFIG_SIZE,
197                    )
198                } else {
199                    return None;
200                };
201                if rest.len() < pos + 2 {
202                    return None;
203                }
204                let len = u16::from_le_bytes(rest[pos..pos + 2].try_into().ok()?) as usize;
205                let data_start = pos + 2;
206                if rest.len() < data_start + len {
207                    return None;
208                }
209                let mut payload = Vec::new();
210                let _ = payload.extend_from_slice(&rest[data_start..data_start + len]);
211                Some(Self::Transmit { config, payload })
212            }
213            6 => Some(Self::DisplayOn),
214            7 => Some(Self::DisplayOff),
215            8 => Some(Self::GetMac),
216            _ => None,
217        }
218    }
219}
220
221/// Firmware → host responses.
222#[allow(clippy::large_enum_variant)]
223#[derive(Debug, Clone, PartialEq)]
224pub enum Response {
225    Pong,
226    Config(RadioConfig),
227    RxPacket {
228        rssi: i16,
229        snr: i16,
230        payload: Vec<u8, MAX_PAYLOAD>,
231    },
232    TxDone,
233    Ok,
234    Error(ErrorCode),
235    MacAddress([u8; 6]),
236}
237
238impl Response {
239    /// Serialize to fixed-size LE bytes. Returns number of bytes written.
240    pub fn write_to(self, buf: &mut [u8]) -> usize {
241        match self {
242            Self::Pong => {
243                buf[0] = 0;
244                1
245            }
246            Self::Config(cfg) => {
247                buf[0] = 1;
248                1 + cfg.write_to(&mut buf[1..])
249            }
250            Self::RxPacket { rssi, snr, payload } => {
251                buf[0] = 2;
252                buf[1..3].copy_from_slice(&rssi.to_le_bytes());
253                buf[3..5].copy_from_slice(&snr.to_le_bytes());
254                buf[5..7].copy_from_slice(&(payload.len() as u16).to_le_bytes());
255                buf[7..7 + payload.len()].copy_from_slice(&payload);
256                7 + payload.len()
257            }
258            Self::TxDone => {
259                buf[0] = 3;
260                1
261            }
262            Self::Ok => {
263                buf[0] = 4;
264                1
265            }
266            Self::Error(code) => {
267                buf[0] = 5;
268                buf[1] = code as u8;
269                2
270            }
271            Self::MacAddress(mac) => {
272                buf[0] = 6;
273                buf[1..7].copy_from_slice(&mac);
274                7
275            }
276        }
277    }
278}
279
280/// Error codes reported to the host.
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282#[cfg_attr(feature = "defmt", derive(defmt::Format))]
283#[repr(u8)]
284pub enum ErrorCode {
285    InvalidConfig = 0,
286    RadioBusy = 1,
287    TxTimeout = 2,
288    // 3 reserved (was CrcError — SX1262 silently drops bad-CRC packets)
289    NotConfigured = 4,
290    NoDisplay = 5,
291}
292
293// ── Tests ───────────────────────────────────────────────────────────
294
295#[cfg(test)]
296#[allow(clippy::unwrap_used, clippy::panic)]
297mod tests {
298    use super::*;
299
300    fn make_config() -> RadioConfig {
301        RadioConfig {
302            freq_hz: 915_000_000,
303            bw: Bandwidth::Khz125,
304            sf: 7,
305            cr: 5,
306            sync_word: 0x3444,
307            tx_power_dbm: 22,
308            preamble_len: 16,
309            cad: 1,
310        }
311    }
312
313    // ── RadioConfig roundtrip ───────────────────────────────────────
314
315    #[test]
316    fn radio_config_roundtrip() {
317        let cfg = make_config();
318        let mut buf = [0u8; RADIO_CONFIG_SIZE];
319        let n = cfg.write_to(&mut buf);
320        assert_eq!(n, RADIO_CONFIG_SIZE);
321        assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
322    }
323
324    #[test]
325    fn radio_config_roundtrip_all_bandwidths() {
326        for bw_val in 0u8..=9 {
327            let bw = Bandwidth::from_u8(bw_val).unwrap();
328            let cfg = RadioConfig {
329                freq_hz: 433_000_000,
330                bw,
331                sf: 12,
332                cr: 8,
333                sync_word: 0x1234,
334                tx_power_dbm: -9,
335                preamble_len: 16,
336                cad: 1,
337            };
338            let mut buf = [0u8; RADIO_CONFIG_SIZE];
339            cfg.write_to(&mut buf);
340            assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
341        }
342    }
343
344    #[test]
345    fn radio_config_roundtrip_negative_power() {
346        let cfg = RadioConfig {
347            tx_power_dbm: TX_POWER_MAX,
348            ..make_config()
349        };
350        let mut buf = [0u8; RADIO_CONFIG_SIZE];
351        cfg.write_to(&mut buf);
352        assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
353    }
354
355    #[test]
356    fn radio_config_from_short_buffer() {
357        let buf = [0u8; RADIO_CONFIG_SIZE - 1];
358        assert!(RadioConfig::from_bytes(&buf).is_none());
359    }
360
361    #[test]
362    fn radio_config_from_empty_buffer() {
363        assert!(RadioConfig::from_bytes(&[]).is_none());
364    }
365
366    #[test]
367    fn radio_config_invalid_bandwidth() {
368        let mut buf = [0u8; RADIO_CONFIG_SIZE];
369        make_config().write_to(&mut buf);
370        buf[4] = 255; // invalid bandwidth
371        assert!(RadioConfig::from_bytes(&buf).is_none());
372    }
373
374    // ── RadioConfig::validate ───────────────────────────────────────
375
376    #[test]
377    fn validate_freq_boundaries() {
378        let power_range = (-9, 22);
379        let base = make_config();
380
381        let mut cfg = RadioConfig {
382            freq_hz: 150_000_000,
383            ..base
384        };
385        assert!(cfg.validate(power_range).is_ok());
386
387        cfg.freq_hz = 960_000_000;
388        assert!(cfg.validate(power_range).is_ok());
389
390        cfg.freq_hz = 149_999_999;
391        assert!(cfg.validate(power_range).is_err());
392
393        cfg.freq_hz = 960_000_001;
394        assert!(cfg.validate(power_range).is_err());
395    }
396
397    #[test]
398    fn validate_sf_boundaries() {
399        let power_range = (-9, 22);
400        let base = make_config();
401
402        for sf in 5..=12 {
403            assert!(RadioConfig { sf, ..base }.validate(power_range).is_ok());
404        }
405        assert!(RadioConfig { sf: 4, ..base }.validate(power_range).is_err());
406        assert!(
407            RadioConfig { sf: 13, ..base }
408                .validate(power_range)
409                .is_err()
410        );
411    }
412
413    #[test]
414    fn validate_cr_boundaries() {
415        let power_range = (-9, 22);
416        let base = make_config();
417
418        for cr in 5..=8 {
419            assert!(RadioConfig { cr, ..base }.validate(power_range).is_ok());
420        }
421        assert!(RadioConfig { cr: 4, ..base }.validate(power_range).is_err());
422        assert!(RadioConfig { cr: 9, ..base }.validate(power_range).is_err());
423    }
424
425    #[test]
426    fn validate_tx_power_max_sentinel() {
427        let cfg = RadioConfig {
428            tx_power_dbm: TX_POWER_MAX,
429            ..make_config()
430        };
431        assert!(cfg.validate((-9, 22)).is_ok());
432    }
433
434    #[test]
435    fn validate_tx_power_out_of_range() {
436        let cfg = RadioConfig {
437            tx_power_dbm: 23,
438            ..make_config()
439        };
440        assert!(cfg.validate((-9, 22)).is_err());
441
442        let cfg = RadioConfig {
443            tx_power_dbm: -10,
444            ..make_config()
445        };
446        assert!(cfg.validate((-9, 22)).is_err());
447    }
448
449    #[test]
450    fn validate_preamble_default_sentinel() {
451        let cfg = RadioConfig {
452            preamble_len: PREAMBLE_DEFAULT,
453            ..make_config()
454        };
455        assert!(cfg.validate((-9, 22)).is_ok());
456    }
457
458    #[test]
459    fn validate_preamble_boundaries() {
460        let power_range = (-9, 22);
461        let base = make_config();
462
463        assert!(
464            RadioConfig {
465                preamble_len: 6,
466                ..base
467            }
468            .validate(power_range)
469            .is_ok()
470        );
471        assert!(
472            RadioConfig {
473                preamble_len: 5,
474                ..base
475            }
476            .validate(power_range)
477            .is_err()
478        );
479    }
480
481    // ── RadioConfig::resolve ──────────────────────────────────────
482
483    #[test]
484    fn resolve_power_max_sentinel() {
485        let cfg = RadioConfig {
486            tx_power_dbm: TX_POWER_MAX,
487            ..make_config()
488        };
489        assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 22);
490    }
491
492    #[test]
493    fn resolve_power_explicit_unchanged() {
494        let cfg = RadioConfig {
495            tx_power_dbm: 10,
496            ..make_config()
497        };
498        assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 10);
499    }
500
501    #[test]
502    fn resolve_preamble_default() {
503        let cfg = RadioConfig {
504            preamble_len: PREAMBLE_DEFAULT,
505            ..make_config()
506        };
507        assert_eq!(cfg.resolve((-9, 22)).preamble_len, 16);
508    }
509
510    #[test]
511    fn resolve_preamble_explicit_unchanged() {
512        let cfg = RadioConfig {
513            preamble_len: 32,
514            ..make_config()
515        };
516        assert_eq!(cfg.resolve((-9, 22)).preamble_len, 32);
517    }
518
519    // ── Command::from_bytes ─────────────────────────────────────────
520
521    #[test]
522    fn command_ping() {
523        assert_eq!(Command::from_bytes(&[0]), Some(Command::Ping));
524    }
525
526    #[test]
527    fn command_get_config() {
528        assert_eq!(Command::from_bytes(&[1]), Some(Command::GetConfig));
529    }
530
531    #[test]
532    fn command_set_config() {
533        let cfg = make_config();
534        let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE];
535        buf[0] = 2;
536        cfg.write_to(&mut buf[1..]);
537        assert_eq!(Command::from_bytes(&buf), Some(Command::SetConfig(cfg)));
538    }
539
540    #[test]
541    fn command_start_stop_rx() {
542        assert_eq!(Command::from_bytes(&[3]), Some(Command::StartRx));
543        assert_eq!(Command::from_bytes(&[4]), Some(Command::StopRx));
544    }
545
546    #[test]
547    fn command_transmit_no_config() {
548        let payload = b"hello";
549        let mut buf = [0u8; 64];
550        buf[0] = 5; // tag
551        buf[1] = 0; // has_config = false
552        buf[2..4].copy_from_slice(&(payload.len() as u16).to_le_bytes());
553        buf[4..9].copy_from_slice(payload);
554
555        match Command::from_bytes(&buf[..9]).unwrap() {
556            Command::Transmit { config, payload: p } => {
557                assert!(config.is_none());
558                assert_eq!(p.as_slice(), b"hello");
559            }
560            _ => panic!("expected Transmit"),
561        }
562    }
563
564    #[test]
565    fn command_transmit_with_config() {
566        let cfg = make_config();
567        let mut buf = [0u8; 64];
568        buf[0] = 5; // tag
569        buf[1] = 1; // has_config = true
570        cfg.write_to(&mut buf[2..]);
571        let payload = b"test";
572        let pos = 2 + RADIO_CONFIG_SIZE;
573        buf[pos..pos + 2].copy_from_slice(&(payload.len() as u16).to_le_bytes());
574        buf[pos + 2..pos + 6].copy_from_slice(payload);
575
576        match Command::from_bytes(&buf[..pos + 6]).unwrap() {
577            Command::Transmit { config, payload: p } => {
578                assert_eq!(config, Some(cfg));
579                assert_eq!(p.as_slice(), b"test");
580            }
581            _ => panic!("expected Transmit"),
582        }
583    }
584
585    #[test]
586    fn command_transmit_empty_payload() {
587        let mut buf = [0u8; 4];
588        buf[0] = 5; // tag
589        buf[1] = 0; // has_config = false
590        buf[2..4].copy_from_slice(&0u16.to_le_bytes());
591
592        match Command::from_bytes(&buf).unwrap() {
593            Command::Transmit { config, payload } => {
594                assert!(config.is_none());
595                assert!(payload.is_empty());
596            }
597            _ => panic!("expected Transmit"),
598        }
599    }
600
601    #[test]
602    fn command_transmit_truncated() {
603        // Tag only — missing has_config byte
604        assert!(Command::from_bytes(&[5]).is_none());
605
606        // has_config=1 but no config bytes
607        assert!(Command::from_bytes(&[5, 1]).is_none());
608
609        // has_config=0 but no length bytes
610        assert!(Command::from_bytes(&[5, 0]).is_none());
611
612        // has_config=0, length says 5 but only 2 bytes of payload
613        let mut buf = [0u8; 6];
614        buf[0] = 5;
615        buf[1] = 0;
616        buf[2..4].copy_from_slice(&5u16.to_le_bytes());
617        buf[4] = 0xAA;
618        buf[5] = 0xBB;
619        assert!(Command::from_bytes(&buf).is_none());
620    }
621
622    #[test]
623    fn command_display_and_mac() {
624        assert_eq!(Command::from_bytes(&[6]), Some(Command::DisplayOn));
625        assert_eq!(Command::from_bytes(&[7]), Some(Command::DisplayOff));
626        assert_eq!(Command::from_bytes(&[8]), Some(Command::GetMac));
627    }
628
629    #[test]
630    fn command_invalid_tag() {
631        assert!(Command::from_bytes(&[9]).is_none());
632        assert!(Command::from_bytes(&[255]).is_none());
633    }
634
635    #[test]
636    fn command_empty_buffer() {
637        assert!(Command::from_bytes(&[]).is_none());
638    }
639
640    // ── Response::write_to ──────────────────────────────────────────
641
642    #[test]
643    fn response_pong() {
644        let mut buf = [0u8; 1];
645        assert_eq!(Response::Pong.write_to(&mut buf), 1);
646        assert_eq!(buf[0], 0);
647    }
648
649    #[test]
650    fn response_config() {
651        let cfg = make_config();
652        let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE];
653        let n = Response::Config(cfg).write_to(&mut buf);
654        assert_eq!(n, 1 + RADIO_CONFIG_SIZE);
655        assert_eq!(buf[0], 1);
656        assert_eq!(RadioConfig::from_bytes(&buf[1..]), Some(cfg));
657    }
658
659    #[test]
660    fn response_rx_packet() {
661        let mut payload = Vec::new();
662        let _ = payload.extend_from_slice(b"data");
663        let mut buf = [0u8; 64];
664        let n = Response::RxPacket {
665            rssi: -80,
666            snr: 10,
667            payload,
668        }
669        .write_to(&mut buf);
670        assert_eq!(n, 7 + 4); // tag(1) + rssi(2) + snr(2) + len(2) + "data"(4)
671        assert_eq!(buf[0], 2);
672        assert_eq!(i16::from_le_bytes([buf[1], buf[2]]), -80);
673        assert_eq!(i16::from_le_bytes([buf[3], buf[4]]), 10);
674        assert_eq!(u16::from_le_bytes([buf[5], buf[6]]), 4);
675        assert_eq!(&buf[7..11], b"data");
676    }
677
678    #[test]
679    fn response_tx_done() {
680        let mut buf = [0u8; 1];
681        assert_eq!(Response::TxDone.write_to(&mut buf), 1);
682        assert_eq!(buf[0], 3);
683    }
684
685    #[test]
686    fn response_ok() {
687        let mut buf = [0u8; 1];
688        assert_eq!(Response::Ok.write_to(&mut buf), 1);
689        assert_eq!(buf[0], 4);
690    }
691
692    #[test]
693    fn response_error_codes() {
694        let mut buf = [0u8; 2];
695        for (code, val) in [
696            (ErrorCode::InvalidConfig, 0),
697            (ErrorCode::RadioBusy, 1),
698            (ErrorCode::TxTimeout, 2),
699            (ErrorCode::NotConfigured, 4),
700            (ErrorCode::NoDisplay, 5),
701        ] {
702            let n = Response::Error(code).write_to(&mut buf);
703            assert_eq!(n, 2);
704            assert_eq!(buf[0], 5);
705            assert_eq!(buf[1], val);
706        }
707    }
708
709    #[test]
710    fn response_mac_address() {
711        let mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF];
712        let mut buf = [0u8; 7];
713        let n = Response::MacAddress(mac).write_to(&mut buf);
714        assert_eq!(n, 7);
715        assert_eq!(buf[0], 6);
716        assert_eq!(&buf[1..7], &mac);
717    }
718}