donglora-protocol 1.1.0

DongLoRa wire protocol types and COBS framing — shared between firmware and host crates
Documentation
//! 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<Self, CommandParseError> {
        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<u8, MAX_OTA_PAYLOAD>,
    },
    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<usize, CommandEncodeError> {
        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<Self, CommandParseError> {
        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<ModulationEncodeError> for CommandEncodeError {
    fn from(e: ModulationEncodeError) -> Self {
        match e {
            ModulationEncodeError::BufferTooSmall => Self::BufferTooSmall,
            ModulationEncodeError::SyncWordTooLong => Self::SyncWordTooLong,
        }
    }
}

impl From<ModulationParseError> 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)
        ));
    }
}