1#![no_std]
7
8pub mod framing;
9
10use heapless::Vec;
11
12pub const MAX_PAYLOAD: usize = 256;
14
15pub const RADIO_CONFIG_SIZE: usize = 13;
17
18pub const TX_POWER_MAX: i8 = i8::MIN; pub const PREAMBLE_DEFAULT: u16 = 0;
23
24#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66#[cfg_attr(feature = "defmt", derive(defmt::Format))]
67pub struct RadioConfig {
68 pub freq_hz: u32,
70 pub bw: Bandwidth,
71 pub sf: u8,
73 pub cr: u8,
75 pub sync_word: u16,
76 pub tx_power_dbm: i8,
79 pub preamble_len: u16,
82 pub cad: u8,
85}
86
87impl RadioConfig {
88 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 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 pub fn cad_enabled(&self) -> bool {
123 self.cad != 0
124 }
125
126 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 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#[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 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 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#[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 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#[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 NotConfigured = 4,
290 NoDisplay = 5,
291}
292
293#[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 #[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; assert!(RadioConfig::from_bytes(&buf).is_none());
372 }
373
374 #[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 #[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 #[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; buf[1] = 0; 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; buf[1] = 1; 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; buf[1] = 0; 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 assert!(Command::from_bytes(&[5]).is_none());
605
606 assert!(Command::from_bytes(&[5, 1]).is_none());
608
609 assert!(Command::from_bytes(&[5, 0]).is_none());
611
612 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 #[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); 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}