1use heapless::Vec as HVec;
18
19use crate::{
20 CommandEncodeError, CommandParseError, MAX_OTA_PAYLOAD, Modulation, ModulationEncodeError,
21 ModulationParseError,
22};
23
24pub const TYPE_PING: u8 = 0x01;
28pub const TYPE_GET_INFO: u8 = 0x02;
30pub const TYPE_SET_CONFIG: u8 = 0x03;
32pub const TYPE_TX: u8 = 0x04;
34pub const TYPE_RX_START: u8 = 0x05;
36pub const TYPE_RX_STOP: u8 = 0x06;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47#[cfg_attr(feature = "defmt", derive(defmt::Format))]
48pub struct TxFlags {
49 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 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#[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 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 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 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 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); 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 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}