//! Host-to-device commands (`PROTOCOL.md §6`). //! //! A `Command` is the semantic content of an H→D frame — it does not //! carry the tag (the tag is a frame-level concern, chosen freshly by //! the host for each outbound command and echoed by the device on the //! matching response). //! //! Encoding turns a `Command` into the payload bytes that go between a //! frame's header and CRC; callers then wrap the (type_id, tag, payload) //! tuple with `frame::encode_frame`. //! //! Parsing reverses: given a frame's `type_id` and `payload`, reconstruct //! the `Command`. Length mismatches and reserved-bit violations are //! returned as `CommandParseError` so the caller can decide whether to //! emit `ERR(ELENGTH)` or `ERR(EPARAM)`. use heapless::Vec as HVec; use crate::{ CommandEncodeError, CommandParseError, MAX_OTA_PAYLOAD, Modulation, ModulationEncodeError, ModulationParseError, }; // ── Message type identifiers ──────────────────────────────────────── /// `PING` (H→D). pub const TYPE_PING: u8 = 0x01; /// `GET_INFO` (H→D). pub const TYPE_GET_INFO: u8 = 0x02; /// `SET_CONFIG` (H→D). pub const TYPE_SET_CONFIG: u8 = 0x03; /// `TX` (H→D). pub const TYPE_TX: u8 = 0x04; /// `RX_START` (H→D). pub const TYPE_RX_START: u8 = 0x05; /// `RX_STOP` (H→D). pub const TYPE_RX_STOP: u8 = 0x06; // ── TX flags ──────────────────────────────────────────────────────── /// `TX` flag byte (`PROTOCOL.md §6.4`). /// /// Bit 0 is `skip_cad`. Bits 1–7 are reserved and MUST be zero on the /// wire; parsing rejects any frame with reserved bits set so the caller /// can emit `ERR(EPARAM)` cleanly. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[cfg_attr(feature = "defmt", derive(defmt::Format))] pub struct TxFlags { /// If true, skip the default CAD-before-TX and transmit immediately. pub skip_cad: bool, } impl TxFlags { pub const fn as_byte(self) -> u8 { if self.skip_cad { 0b0000_0001 } else { 0 } } /// Parse the wire byte. Returns `Err(CommandParseError::ReservedBitSet)` /// if any bit besides bit 0 is nonzero. pub const fn from_byte(b: u8) -> Result { if b & !0b0000_0001 != 0 { return Err(CommandParseError::ReservedBitSet); } Ok(Self { skip_cad: b & 0b0000_0001 != 0, }) } } // ── Command enum ──────────────────────────────────────────────────── /// Host-to-device command. /// /// `Tx.data` is owned so a parsed `Command` outlives the frame buffer it /// came from. This is a single memcpy over the radio packet — trivial /// relative to LoRa airtime, and it simplifies the firmware's statechart /// (which needs to carry the pending TX across state transitions). #[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "defmt", derive(defmt::Format))] pub enum Command { Ping, GetInfo, SetConfig(Modulation), Tx { flags: TxFlags, data: HVec, }, RxStart, RxStop, } impl Command { /// Wire-level `type_id` for this command. pub const fn type_id(&self) -> u8 { match self { Self::Ping => TYPE_PING, Self::GetInfo => TYPE_GET_INFO, Self::SetConfig(_) => TYPE_SET_CONFIG, Self::Tx { .. } => TYPE_TX, Self::RxStart => TYPE_RX_START, Self::RxStop => TYPE_RX_STOP, } } /// Encode the command's payload (the bytes between the frame header /// and the CRC) into `buf`. Returns the number of bytes written. pub fn encode_payload(&self, buf: &mut [u8]) -> Result { match self { Self::Ping | Self::GetInfo | Self::RxStart | Self::RxStop => Ok(0), Self::SetConfig(m) => m.encode(buf).map_err(CommandEncodeError::from), Self::Tx { flags, data } => { if data.is_empty() { return Err(CommandEncodeError::EmptyTxPayload); } if data.len() > MAX_OTA_PAYLOAD { return Err(CommandEncodeError::PayloadTooLarge); } let total = 1 + data.len(); if buf.len() < total { return Err(CommandEncodeError::BufferTooSmall); } buf[0] = flags.as_byte(); buf[1..total].copy_from_slice(data); Ok(total) } } } /// Parse a (`type_id`, `payload`) pair into a `Command`. The tag is /// handled at the frame level and is not part of a command's /// semantic content. pub fn parse(type_id: u8, payload: &[u8]) -> Result { match type_id { TYPE_PING => { if !payload.is_empty() { return Err(CommandParseError::WrongLength); } Ok(Self::Ping) } TYPE_GET_INFO => { if !payload.is_empty() { return Err(CommandParseError::WrongLength); } Ok(Self::GetInfo) } TYPE_SET_CONFIG => Modulation::decode(payload) .map(Self::SetConfig) .map_err(CommandParseError::from), TYPE_TX => { if payload.is_empty() { return Err(CommandParseError::WrongLength); } let flags = TxFlags::from_byte(payload[0])?; let body = &payload[1..]; if body.is_empty() { return Err(CommandParseError::WrongLength); } if body.len() > MAX_OTA_PAYLOAD { return Err(CommandParseError::WrongLength); } let mut data = HVec::new(); data.extend_from_slice(body) .map_err(|_| CommandParseError::WrongLength)?; Ok(Self::Tx { flags, data }) } TYPE_RX_START => { if !payload.is_empty() { return Err(CommandParseError::WrongLength); } Ok(Self::RxStart) } TYPE_RX_STOP => { if !payload.is_empty() { return Err(CommandParseError::WrongLength); } Ok(Self::RxStop) } _ => Err(CommandParseError::UnknownType), } } } impl From for CommandEncodeError { fn from(e: ModulationEncodeError) -> Self { match e { ModulationEncodeError::BufferTooSmall => Self::BufferTooSmall, ModulationEncodeError::SyncWordTooLong => Self::SyncWordTooLong, } } } impl From for CommandParseError { fn from(e: ModulationParseError) -> Self { match e { ModulationParseError::WrongLength { .. } | ModulationParseError::TooShort => { Self::WrongLength } ModulationParseError::InvalidField => Self::InvalidField, ModulationParseError::UnknownModulation => Self::UnknownModulation, } } } #[cfg(test)] #[allow(clippy::panic, clippy::unwrap_used)] mod tests { use super::*; use crate::{LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode}; fn sample_lora() -> LoRaConfig { LoRaConfig { freq_hz: 868_100_000, sf: 7, bw: LoRaBandwidth::Khz125, cr: LoRaCodingRate::Cr4_5, preamble_len: 8, sync_word: 0x1424, tx_power_dbm: 14, header_mode: LoRaHeaderMode::Explicit, payload_crc: true, iq_invert: false, } } #[test] fn type_ids_match_spec() { assert_eq!(TYPE_PING, 0x01); assert_eq!(TYPE_GET_INFO, 0x02); assert_eq!(TYPE_SET_CONFIG, 0x03); assert_eq!(TYPE_TX, 0x04); assert_eq!(TYPE_RX_START, 0x05); assert_eq!(TYPE_RX_STOP, 0x06); } #[test] fn tx_flags_roundtrip() { assert_eq!(TxFlags::default().as_byte(), 0); assert_eq!(TxFlags { skip_cad: true }.as_byte(), 1); assert_eq!(TxFlags::from_byte(0).unwrap(), TxFlags::default()); assert_eq!(TxFlags::from_byte(1).unwrap(), TxFlags { skip_cad: true }); } #[test] fn tx_flags_reject_reserved_bits() { assert!(matches!( TxFlags::from_byte(0x02), Err(CommandParseError::ReservedBitSet) )); assert!(matches!( TxFlags::from_byte(0x80), Err(CommandParseError::ReservedBitSet) )); } #[test] fn roundtrip_empty_commands() { for cmd in [ Command::Ping, Command::GetInfo, Command::RxStart, Command::RxStop, ] { let mut buf = [0u8; 4]; let n = cmd.encode_payload(&mut buf).unwrap(); assert_eq!(n, 0); assert_eq!(Command::parse(cmd.type_id(), &buf[..n]).unwrap(), cmd); } } #[test] fn roundtrip_set_config_lora() { let cmd = Command::SetConfig(Modulation::LoRa(sample_lora())); let mut buf = [0u8; 32]; let n = cmd.encode_payload(&mut buf).unwrap(); // 1 (modulation_id) + 15 (LoRa wire size). assert_eq!(n, 16); assert_eq!(Command::parse(cmd.type_id(), &buf[..n]).unwrap(), cmd); } #[test] fn roundtrip_tx_with_cad() { let mut data = HVec::new(); data.extend_from_slice(b"Hello").unwrap(); let cmd = Command::Tx { flags: TxFlags { skip_cad: false }, data, }; let mut buf = [0u8; 8]; let n = cmd.encode_payload(&mut buf).unwrap(); assert_eq!(n, 6); // flags + 5 data bytes assert_eq!(buf[0], 0x00); assert_eq!(&buf[1..n], b"Hello"); assert_eq!(Command::parse(cmd.type_id(), &buf[..n]).unwrap(), cmd); } #[test] fn roundtrip_tx_skip_cad() { let mut data = HVec::new(); data.extend_from_slice(b"URGENT").unwrap(); let cmd = Command::Tx { flags: TxFlags { skip_cad: true }, data, }; let mut buf = [0u8; 8]; let n = cmd.encode_payload(&mut buf).unwrap(); assert_eq!(n, 7); assert_eq!(buf[0], 0x01); assert_eq!(&buf[1..n], b"URGENT"); assert_eq!(Command::parse(cmd.type_id(), &buf[..n]).unwrap(), cmd); } #[test] fn tx_rejects_empty_payload() { let cmd = Command::Tx { flags: TxFlags::default(), data: HVec::new(), }; let mut buf = [0u8; 2]; assert!(matches!( cmd.encode_payload(&mut buf), Err(CommandEncodeError::EmptyTxPayload) )); } #[test] fn tx_parse_rejects_empty_payload() { // Just the flags byte, no data. assert!(matches!( Command::parse(TYPE_TX, &[0x00]), Err(CommandParseError::WrongLength) )); } #[test] fn ping_rejects_nonempty_payload() { assert!(matches!( Command::parse(TYPE_PING, &[0x00]), Err(CommandParseError::WrongLength) )); } #[test] fn unknown_type_rejects() { assert!(matches!( Command::parse(0x10, &[]), Err(CommandParseError::UnknownType) )); assert!(matches!( Command::parse(0xFF, &[0xDE, 0xAD]), Err(CommandParseError::UnknownType) )); } }