donglora-protocol 1.1.0

DongLoRa wire protocol types and COBS framing — shared between firmware and host crates
Documentation
//! `GET_INFO` response payload (`PROTOCOL.md §6.2`).
//!
//! Carries the device's identity, firmware version, radio type,
//! capability bitmaps, queue depths, frequency range, TX-power range,
//! and optional MCU / radio unique identifiers. All fields are stable
//! for the lifetime of the session; hosts MAY cache the whole struct.

use crate::{InfoParseError, MAX_MCU_UID_LEN, MAX_RADIO_UID_LEN};

/// Capability bitmap bit positions (`PROTOCOL.md §9`).
///
/// Hosts should consult these before attempting `SET_CONFIG` with a
/// particular modulation. Undefined bits MUST be zero in v1.0 and MUST
/// be ignored by hosts that don't understand them.
pub mod cap {
    // Modulations (bits 0–15)
    //
    // Bit 0 is written as a bare `1` (not `1 << 0`) because `1 << 0` and
    // `1 >> 0` both evaluate to 1 — the two-operator form produces an
    // equivalent mutation target that no test can distinguish. Every
    // other bit keeps the `1 << N` form for clarity.
    pub const LORA: u64 = 1;
    pub const FSK: u64 = 1 << 1;
    pub const GFSK: u64 = 1 << 2;
    pub const LR_FHSS: u64 = 1 << 3;
    pub const FLRC: u64 = 1 << 4;
    pub const MSK: u64 = 1 << 5;
    pub const GMSK: u64 = 1 << 6;
    pub const BLE_COMPATIBLE: u64 = 1 << 7;

    // Radio features (bits 16–31)
    pub const CAD_BEFORE_TX: u64 = 1 << 16;
    pub const IQ_INVERSION: u64 = 1 << 17;
    pub const RANGING: u64 = 1 << 18;
    pub const GNSS_SCAN: u64 = 1 << 19;
    pub const WIFI_MAC_SCAN: u64 = 1 << 20;
    pub const SPECTRAL_SCAN: u64 = 1 << 21;
    pub const FULL_DUPLEX: u64 = 1 << 22;

    // Protocol features (bits 32–47)
    pub const MULTI_CLIENT: u64 = 1 << 32;
}

/// `GET_INFO` response payload.
///
/// `mcu_uid` and `radio_uid` are fixed-capacity arrays (`MAX_MCU_UID_LEN`
/// and `MAX_RADIO_UID_LEN` respectively). Only the first `*_len` bytes
/// are meaningful on the wire; the rest are zero-padded and ignored.
///
/// `radio_chip_id` is stored as the raw u16. Use `chip_id()` to project
/// it into the `RadioChipId` enum when convenient; unassigned values
/// stay representable so a newer device advertising a chip this codec
/// hasn't heard of still round-trips cleanly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub struct Info {
    pub proto_major: u8,
    pub proto_minor: u8,
    pub fw_major: u8,
    pub fw_minor: u8,
    pub fw_patch: u8,
    pub radio_chip_id: u16,
    pub capability_bitmap: u64,
    pub supported_sf_bitmap: u16,
    pub supported_bw_bitmap: u16,
    pub max_payload_bytes: u16,
    pub rx_queue_capacity: u16,
    pub tx_queue_capacity: u16,
    pub freq_min_hz: u32,
    pub freq_max_hz: u32,
    pub tx_power_min_dbm: i8,
    pub tx_power_max_dbm: i8,
    pub mcu_uid_len: u8,
    pub mcu_uid: [u8; MAX_MCU_UID_LEN],
    pub radio_uid_len: u8,
    pub radio_uid: [u8; MAX_RADIO_UID_LEN],
}

impl Info {
    /// Size of the fixed-layout prefix (through `radio_uid_len`'s byte).
    /// The spec locates `radio_uid_len` at offset `36 + mcu_uid_len`; the
    /// 37 bytes of fixed fields before `mcu_uid` plus 1 for the
    /// `radio_uid_len` byte gives the absolute minimum wire size (with
    /// `mcu_uid_len = 0` and `radio_uid_len = 0`).
    pub const MIN_WIRE_SIZE: usize = 37;

    /// Project `radio_chip_id` into the enum. Returns `None` for
    /// unassigned values so callers can decide how to handle them.
    pub fn chip_id(&self) -> Option<crate::RadioChipId> {
        crate::RadioChipId::from_u16(self.radio_chip_id)
    }

    /// True if the device advertises a given capability bit.
    pub fn supports(&self, mask: u64) -> bool {
        self.capability_bitmap & mask != 0
    }

    /// Encode into `buf`. Returns the number of bytes written
    /// (`37 + mcu_uid_len + radio_uid_len`).
    pub fn encode(&self, buf: &mut [u8]) -> Result<usize, InfoParseError> {
        let mcu_n = self.mcu_uid_len as usize;
        let radio_n = self.radio_uid_len as usize;
        if mcu_n > MAX_MCU_UID_LEN || radio_n > MAX_RADIO_UID_LEN {
            return Err(InfoParseError::InvalidField);
        }
        let total = Self::MIN_WIRE_SIZE + mcu_n + radio_n;
        if buf.len() < total {
            return Err(InfoParseError::BufferTooSmall);
        }
        buf[0] = self.proto_major;
        buf[1] = self.proto_minor;
        buf[2] = self.fw_major;
        buf[3] = self.fw_minor;
        buf[4] = self.fw_patch;
        buf[5..7].copy_from_slice(&self.radio_chip_id.to_le_bytes());
        buf[7..15].copy_from_slice(&self.capability_bitmap.to_le_bytes());
        buf[15..17].copy_from_slice(&self.supported_sf_bitmap.to_le_bytes());
        buf[17..19].copy_from_slice(&self.supported_bw_bitmap.to_le_bytes());
        buf[19..21].copy_from_slice(&self.max_payload_bytes.to_le_bytes());
        buf[21..23].copy_from_slice(&self.rx_queue_capacity.to_le_bytes());
        buf[23..25].copy_from_slice(&self.tx_queue_capacity.to_le_bytes());
        buf[25..29].copy_from_slice(&self.freq_min_hz.to_le_bytes());
        buf[29..33].copy_from_slice(&self.freq_max_hz.to_le_bytes());
        buf[33] = self.tx_power_min_dbm as u8;
        buf[34] = self.tx_power_max_dbm as u8;
        buf[35] = self.mcu_uid_len;
        buf[36..36 + mcu_n].copy_from_slice(&self.mcu_uid[..mcu_n]);
        let radio_len_idx = 36 + mcu_n;
        buf[radio_len_idx] = self.radio_uid_len;
        let radio_start = radio_len_idx + 1;
        buf[radio_start..radio_start + radio_n].copy_from_slice(&self.radio_uid[..radio_n]);
        Ok(total)
    }

    /// Decode from `buf`. The full slice must be the `GET_INFO` payload.
    pub fn decode(buf: &[u8]) -> Result<Self, InfoParseError> {
        if buf.len() < Self::MIN_WIRE_SIZE {
            return Err(InfoParseError::TooShort);
        }
        let mcu_uid_len = buf[35];
        if mcu_uid_len as usize > MAX_MCU_UID_LEN {
            return Err(InfoParseError::InvalidField);
        }
        let mcu_n = mcu_uid_len as usize;
        let radio_len_idx = 36 + mcu_n;
        if buf.len() < radio_len_idx + 1 {
            return Err(InfoParseError::TooShort);
        }
        let radio_uid_len = buf[radio_len_idx];
        if radio_uid_len as usize > MAX_RADIO_UID_LEN {
            return Err(InfoParseError::InvalidField);
        }
        let radio_n = radio_uid_len as usize;
        let expected_total = Self::MIN_WIRE_SIZE + mcu_n + radio_n;
        if buf.len() < expected_total {
            return Err(InfoParseError::TooShort);
        }
        // Minor-version extensions may append fields; trailing bytes are
        // allowed. But we at least require the declared UIDs to fit.
        let mut mcu_uid = [0u8; MAX_MCU_UID_LEN];
        mcu_uid[..mcu_n].copy_from_slice(&buf[36..36 + mcu_n]);
        let radio_start = radio_len_idx + 1;
        let mut radio_uid = [0u8; MAX_RADIO_UID_LEN];
        radio_uid[..radio_n].copy_from_slice(&buf[radio_start..radio_start + radio_n]);

        Ok(Self {
            proto_major: buf[0],
            proto_minor: buf[1],
            fw_major: buf[2],
            fw_minor: buf[3],
            fw_patch: buf[4],
            radio_chip_id: u16::from_le_bytes([buf[5], buf[6]]),
            capability_bitmap: u64::from_le_bytes([
                buf[7], buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14],
            ]),
            supported_sf_bitmap: u16::from_le_bytes([buf[15], buf[16]]),
            supported_bw_bitmap: u16::from_le_bytes([buf[17], buf[18]]),
            max_payload_bytes: u16::from_le_bytes([buf[19], buf[20]]),
            rx_queue_capacity: u16::from_le_bytes([buf[21], buf[22]]),
            tx_queue_capacity: u16::from_le_bytes([buf[23], buf[24]]),
            freq_min_hz: u32::from_le_bytes([buf[25], buf[26], buf[27], buf[28]]),
            freq_max_hz: u32::from_le_bytes([buf[29], buf[30], buf[31], buf[32]]),
            tx_power_min_dbm: buf[33] as i8,
            tx_power_max_dbm: buf[34] as i8,
            mcu_uid_len,
            mcu_uid,
            radio_uid_len,
            radio_uid,
        })
    }
}

#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
    use super::*;
    use crate::RadioChipId;

    fn sample() -> Info {
        let mut mcu = [0u8; MAX_MCU_UID_LEN];
        mcu[..8].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67]);
        Info {
            proto_major: 1,
            proto_minor: 0,
            fw_major: 0,
            fw_minor: 1,
            fw_patch: 0,
            radio_chip_id: RadioChipId::Sx1262.as_u16(),
            capability_bitmap: cap::LORA | cap::FSK | cap::CAD_BEFORE_TX,
            // Bits 5..12 = SF5..SF12
            supported_sf_bitmap: 0x1FE0,
            // Bits 0..9 = all sub-GHz BW enum values
            supported_bw_bitmap: 0x03FF,
            max_payload_bytes: 255,
            rx_queue_capacity: 64,
            tx_queue_capacity: 16,
            freq_min_hz: 150_000_000,
            freq_max_hz: 960_000_000,
            tx_power_min_dbm: -9,
            tx_power_max_dbm: 22,
            mcu_uid_len: 8,
            mcu_uid: mcu,
            radio_uid_len: 0,
            radio_uid: [0u8; MAX_RADIO_UID_LEN],
        }
    }

    #[test]
    fn appendix_c22_encode_matches_spec() {
        // PROTOCOL.md §C.2.2 — SX1262 board description. The spec's
        // pre-COBS payload (after stripping the 3-byte response header:
        // type 0x80, tag 0x0002) is:
        //
        //   01 00 00 01 00 02 00 03 00 01 00 00 00 00 00 00
        //   E0 1F FF 03 FF 00 40 00 10 00 80 D1 F0 08 00 70
        //   38 39 F7 16 08 DE AD BE EF 01 23 45 67 00
        //
        // 45 bytes: 37 fixed + 8 mcu_uid + 0 radio_uid.
        let mut buf = [0u8; 128];
        let n = sample().encode(&mut buf).unwrap();
        assert_eq!(n, 45);
        let expected: [u8; 45] = [
            0x01, 0x00, 0x00, 0x01, 0x00, // proto + fw
            0x02, 0x00, // radio_chip_id = 0x0002 (SX1262)
            0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, // capability_bitmap
            0xE0, 0x1F, // supported_sf_bitmap = 0x1FE0
            0xFF, 0x03, // supported_bw_bitmap = 0x03FF
            0xFF, 0x00, // max_payload_bytes = 255
            0x40, 0x00, // rx_queue_capacity = 64
            0x10, 0x00, // tx_queue_capacity = 16
            0x80, 0xD1, 0xF0, 0x08, // freq_min_hz = 150_000_000
            0x00, 0x70, 0x38, 0x39, // freq_max_hz = 960_000_000
            0xF7, // tx_power_min_dbm = -9
            0x16, // tx_power_max_dbm = 22
            0x08, // mcu_uid_len = 8
            0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67, // mcu_uid
            0x00, // radio_uid_len = 0
        ];
        assert_eq!(&buf[..n], &expected);
    }

    #[test]
    fn roundtrip() {
        let info = sample();
        let mut buf = [0u8; 128];
        let n = info.encode(&mut buf).unwrap();
        let decoded = Info::decode(&buf[..n]).unwrap();
        assert_eq!(decoded, info);
    }

    #[test]
    fn chip_id_projection() {
        let info = sample();
        assert_eq!(info.chip_id(), Some(RadioChipId::Sx1262));

        let unknown = Info {
            radio_chip_id: 0xFFFF,
            ..info
        };
        assert_eq!(unknown.chip_id(), None);
    }

    #[test]
    fn supports_bits() {
        let info = sample();
        assert!(info.supports(cap::LORA));
        assert!(info.supports(cap::FSK));
        assert!(info.supports(cap::CAD_BEFORE_TX));
        assert!(!info.supports(cap::MULTI_CLIENT));
        assert!(!info.supports(cap::LR_FHSS));
    }

    #[test]
    fn rejects_oversized_uids() {
        let mut info = sample();
        info.mcu_uid_len = (MAX_MCU_UID_LEN + 1) as u8;
        let mut buf = [0u8; 128];
        assert!(info.encode(&mut buf).is_err());

        info.mcu_uid_len = 0;
        info.radio_uid_len = (MAX_RADIO_UID_LEN + 1) as u8;
        assert!(info.encode(&mut buf).is_err());
    }

    #[test]
    fn rejects_short_buffer_on_decode() {
        assert!(matches!(
            Info::decode(&[0u8; 10]),
            Err(InfoParseError::TooShort)
        ));
    }

    #[test]
    fn rejects_declared_uid_overrun() {
        let mut buf = [0u8; 128];
        let mut info = sample();
        info.mcu_uid_len = 16;
        let n = info.encode(&mut buf).unwrap();
        // Shorten the wire beyond what mcu_uid_len claims.
        let truncated = n - 4;
        assert!(Info::decode(&buf[..truncated]).is_err());
    }
}