donglora-protocol 1.1.0

DongLoRa wire protocol types and COBS framing — shared between firmware and host crates
Documentation
//! DongLoRa Protocol frame format and streaming decoder (`PROTOCOL.md §2`).
//!
//! Every DongLoRa Protocol frame on the wire is `COBS(type || tag_le || payload ||
//! crc_le) || 0x00`. This module owns the wire-level codec: it does
//! nothing protocol-semantic (no enum matching, no tag validation) —
//! just framing, CRC, and COBS.
//!
//! - `encode_frame` builds a complete wire-ready frame (COBS-encoded,
//!   `0x00`-terminated) given `(type_id, tag, payload)`.
//! - `FrameDecoder` accumulates bytes, splitting on `0x00`, and emits
//!   `FrameResult` values through a caller-provided closure. `FrameResult`
//!   is `Ok` with borrowed pre-payload-and-tag slices on success, or
//!   `Err` with a `FrameError` variant on CRC/COBS/length failure.
//!
//! The decoder's internal scratch borrows into the callback, so parsing
//! is zero-copy above the framing layer. Typed parsing (`Command`,
//! `Response`, `Event`) consumes those borrowed slices.

use crate::{
    FRAME_HEADER_SIZE, FRAME_TRAILER_SIZE, FrameDecodeError, FrameEncodeError, MAX_PAYLOAD_FIELD,
    MAX_PRE_COBS_FRAME, MAX_WIRE_FRAME, crc::crc16,
};

/// Outcome of attempting to decode one frame delimited by a `0x00` byte.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum FrameResult<'a> {
    Ok {
        type_id: u8,
        tag: u16,
        payload: &'a [u8],
    },
    Err(FrameDecodeError),
}

/// Worst-case COBS-encoded size for a maximum-length pre-COBS frame.
/// Equal to `MAX_WIRE_FRAME - 1` (the `-1` accounts for the trailing
/// `0x00` sentinel). Public so tests can pin its exact value against
/// the derivation in `lib.rs`.
pub const MAX_COBS_ENCODED: usize = MAX_WIRE_FRAME - 1;

/// Encode `(type_id, tag, payload)` as a complete DongLoRa Protocol frame (COBS-encoded,
/// `0x00`-terminated). Returns the number of bytes written to `out`.
///
/// `out` must be at least `MAX_WIRE_FRAME` bytes to satisfy the worst
/// case across all frame shapes. Smaller buffers succeed when the actual
/// frame fits.
pub fn encode_frame(
    type_id: u8,
    tag: u16,
    payload: &[u8],
    out: &mut [u8],
) -> Result<usize, FrameEncodeError> {
    if payload.len() > MAX_PAYLOAD_FIELD {
        return Err(FrameEncodeError::PayloadTooLarge);
    }
    let pre_cobs_len = FRAME_HEADER_SIZE + payload.len() + FRAME_TRAILER_SIZE;

    let mut scratch = [0u8; MAX_PRE_COBS_FRAME];
    scratch[0] = type_id;
    scratch[1..3].copy_from_slice(&tag.to_le_bytes());
    scratch[3..3 + payload.len()].copy_from_slice(payload);
    let crc = crc16(&scratch[..FRAME_HEADER_SIZE + payload.len()]);
    scratch[FRAME_HEADER_SIZE + payload.len()..pre_cobs_len].copy_from_slice(&crc.to_le_bytes());

    let mut cobs_scratch = [0u8; MAX_COBS_ENCODED];
    let encoded_len = ucobs::encode(&scratch[..pre_cobs_len], &mut cobs_scratch)
        .ok_or(FrameEncodeError::CobsEncode)?;
    let total = encoded_len + 1;
    if out.len() < total {
        return Err(FrameEncodeError::BufferTooSmall);
    }
    out[..encoded_len].copy_from_slice(&cobs_scratch[..encoded_len]);
    out[encoded_len] = 0x00;
    Ok(total)
}

/// Streaming accumulator for inbound bytes. Emits one `FrameResult` per
/// `0x00`-delimited frame encountered in the feed.
///
/// Overflow policy: if accumulated bytes exceed the maximum COBS-encoded
/// size without a delimiter, the buffer is reset and the next `0x00` is
/// treated as the start of a fresh frame. A `FrameResult::Err` is NOT
/// emitted for the dropped bytes — the bytes are simply never classified
/// (the device or host will observe a dropped response and time out at
/// the command level).
pub struct FrameDecoder {
    buf: [u8; MAX_COBS_ENCODED],
    len: usize,
    overflowed: bool,
}

impl FrameDecoder {
    pub const fn new() -> Self {
        Self {
            buf: [0u8; MAX_COBS_ENCODED],
            len: 0,
            overflowed: false,
        }
    }

    /// Discard any partial frame currently buffered.
    pub fn reset(&mut self) {
        self.len = 0;
        self.overflowed = false;
    }

    /// Feed `data` into the accumulator. For every complete frame
    /// detected, call `on_frame` with the result.
    pub fn feed<F: FnMut(FrameResult<'_>)>(&mut self, data: &[u8], mut on_frame: F) {
        for &byte in data {
            if byte == 0x00 {
                if self.overflowed || self.len == 0 {
                    // Overflow recovery or stray sentinel: drop silently.
                    self.len = 0;
                    self.overflowed = false;
                    continue;
                }
                let mut decoded = [0u8; MAX_PRE_COBS_FRAME];
                match ucobs::decode(&self.buf[..self.len], &mut decoded) {
                    Some(n) => {
                        emit_decoded(&decoded[..n], &mut on_frame);
                    }
                    None => on_frame(FrameResult::Err(FrameDecodeError::Cobs)),
                }
                self.len = 0;
                continue;
            }
            if self.overflowed {
                // Still waiting for the next 0x00 to resync; drop this byte.
                continue;
            }
            if self.len < self.buf.len() {
                self.buf[self.len] = byte;
                self.len += 1;
            } else {
                self.overflowed = true;
                self.len = 0;
            }
        }
    }
}

impl Default for FrameDecoder {
    fn default() -> Self {
        Self::new()
    }
}

fn emit_decoded<F: FnMut(FrameResult<'_>)>(decoded: &[u8], on_frame: &mut F) {
    if decoded.len() < FRAME_HEADER_SIZE + FRAME_TRAILER_SIZE {
        on_frame(FrameResult::Err(FrameDecodeError::TooShort));
        return;
    }
    let crc_start = decoded.len() - FRAME_TRAILER_SIZE;
    let body = &decoded[..crc_start];
    let expected = crc16(body);
    let got = u16::from_le_bytes([decoded[crc_start], decoded[crc_start + 1]]);
    if expected != got {
        on_frame(FrameResult::Err(FrameDecodeError::Crc));
        return;
    }
    let type_id = body[0];
    let tag = u16::from_le_bytes([body[1], body[2]]);
    let payload = &body[FRAME_HEADER_SIZE..];
    on_frame(FrameResult::Ok {
        type_id,
        tag,
        payload,
    });
}

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

    #[test]
    fn max_cobs_encoded_is_wire_frame_minus_one() {
        // Kills `MAX_COBS_ENCODED = MAX_WIRE_FRAME - 1` with `+1` / `/1`
        // mutants (285 and 284 respectively, both ≠ 283).
        assert_eq!(MAX_COBS_ENCODED, 283);
        assert_eq!(MAX_COBS_ENCODED, MAX_WIRE_FRAME - 1);
    }

    fn roundtrip(type_id: u8, tag: u16, payload: &[u8]) {
        let mut wire = [0u8; MAX_WIRE_FRAME];
        let n = encode_frame(type_id, tag, payload, &mut wire).unwrap();

        let mut decoder = FrameDecoder::new();
        let mut emitted: heapless::Vec<(u8, u16, heapless::Vec<u8, 300>), 4> = heapless::Vec::new();
        decoder.feed(&wire[..n], |res| match res {
            FrameResult::Ok {
                type_id,
                tag,
                payload,
            } => {
                let mut p = heapless::Vec::new();
                p.extend_from_slice(payload).unwrap();
                emitted.push((type_id, tag, p)).unwrap();
            }
            FrameResult::Err(e) => panic!("decode error: {:?}", e),
        });
        assert_eq!(emitted.len(), 1);
        assert_eq!(emitted[0].0, type_id);
        assert_eq!(emitted[0].1, tag);
        assert_eq!(emitted[0].2.as_slice(), payload);
    }

    #[test]
    fn empty_payload() {
        roundtrip(0x01, 0x0001, &[]);
    }

    #[test]
    fn one_byte_payload() {
        roundtrip(0x04, 0x1234, &[0xAA]);
    }

    #[test]
    fn max_payload() {
        let big = [0x42u8; MAX_PAYLOAD_FIELD];
        roundtrip(0xC0, 0x0000, &big);
    }

    #[test]
    fn rejects_too_large_payload() {
        let big = [0u8; MAX_PAYLOAD_FIELD + 1];
        let mut wire = [0u8; MAX_WIRE_FRAME];
        assert!(matches!(
            encode_frame(0x01, 1, &big, &mut wire),
            Err(FrameEncodeError::PayloadTooLarge)
        ));
    }

    #[test]
    fn rejects_small_output_buffer() {
        let mut tiny = [0u8; 4];
        assert!(matches!(
            encode_frame(0x01, 1, b"hello", &mut tiny),
            Err(FrameEncodeError::BufferTooSmall)
        ));
    }

    #[test]
    fn detects_crc_corruption() {
        let mut wire = [0u8; MAX_WIRE_FRAME];
        let n = encode_frame(0x04, 0x0042, b"hello", &mut wire).unwrap();
        // Flip a non-sentinel byte mid-frame. This will corrupt the CRC.
        wire[4] ^= 0xFF;

        let mut decoder = FrameDecoder::new();
        let mut got_err = None;
        decoder.feed(&wire[..n], |res| {
            if let FrameResult::Err(e) = res {
                got_err = Some(e);
            }
        });
        assert!(matches!(
            got_err,
            Some(FrameDecodeError::Crc | FrameDecodeError::Cobs)
        ));
    }

    #[test]
    fn resync_after_garbage() {
        let mut decoder = FrameDecoder::new();

        // Leading garbage with no 0x00 is accumulated silently.
        decoder.feed(&[0xAA, 0xBB, 0xCC], |res| {
            panic!("unexpected frame: {:?}", res);
        });

        // A first 0x00 closes the bogus frame — expect a decode error.
        let mut errs = 0;
        let mut oks = 0;
        decoder.feed(&[0x00], |res| match res {
            FrameResult::Err(_) => errs += 1,
            FrameResult::Ok { .. } => oks += 1,
        });
        assert!(errs >= 1);
        assert_eq!(oks, 0);

        // Now a clean frame decodes normally.
        let mut wire = [0u8; MAX_WIRE_FRAME];
        let n = encode_frame(0x80, 0x0001, b"", &mut wire).unwrap();
        let mut got_type = None;
        decoder.feed(&wire[..n], |res| {
            if let FrameResult::Ok { type_id, .. } = res {
                got_type = Some(type_id);
            }
        });
        assert_eq!(got_type, Some(0x80));
    }

    #[test]
    fn overflow_resets_and_continues() {
        let mut decoder = FrameDecoder::new();
        // Overrun the buffer with a long non-zero run.
        let bomb = [0x55u8; MAX_COBS_ENCODED + 20];
        decoder.feed(&bomb, |res| panic!("unexpected frame: {:?}", res));

        // The next 0x00 clears the overflow state without emitting a
        // spurious error (we never saw a sane frame candidate).
        decoder.feed(&[0x00], |res| panic!("unexpected frame: {:?}", res));

        // A clean frame still decodes.
        let mut wire = [0u8; MAX_WIRE_FRAME];
        let n = encode_frame(0x01, 0x0007, &[], &mut wire).unwrap();
        let mut got_type_tag = None;
        decoder.feed(&wire[..n], |res| {
            if let FrameResult::Ok { type_id, tag, .. } = res {
                got_type_tag = Some((type_id, tag));
            }
        });
        assert_eq!(got_type_tag, Some((0x01, 0x0007)));
    }

    #[test]
    fn multiple_frames_in_one_feed() {
        let mut wire = [0u8; MAX_WIRE_FRAME * 3];
        let a = encode_frame(0x01, 1, &[], &mut wire).unwrap();
        let b = encode_frame(0x02, 2, &[], &mut wire[a..]).unwrap();
        let c = encode_frame(0x03, 3, &[], &mut wire[a + b..]).unwrap();
        let total = a + b + c;

        let mut decoder = FrameDecoder::new();
        let mut tags: heapless::Vec<u16, 4> = heapless::Vec::new();
        decoder.feed(&wire[..total], |res| {
            if let FrameResult::Ok { tag, .. } = res {
                tags.push(tag).unwrap();
            }
        });
        assert_eq!(tags.as_slice(), &[1, 2, 3]);
    }

    #[test]
    fn detects_short_decoded_frame() {
        // Craft a COBS-encoded 1-byte pre-COBS frame (below 5-byte min).
        // COBS of [0x42] is [0x02, 0x42].
        let wire = [0x02, 0x42, 0x00];
        let mut decoder = FrameDecoder::new();
        let mut got_err = None;
        decoder.feed(&wire, |res| {
            if let FrameResult::Err(e) = res {
                got_err = Some(e);
            }
        });
        assert_eq!(got_err, Some(FrameDecodeError::TooShort));
    }

    #[test]
    fn spec_ping_example() {
        // PROTOCOL.md §C.2.1 — H→D PING, tag=0x0001.
        // Expected on-wire: 03 01 01 03 9D C8 00
        let mut wire = [0u8; MAX_WIRE_FRAME];
        let n = encode_frame(0x01, 0x0001, &[], &mut wire).unwrap();
        assert_eq!(&wire[..n], &[0x03, 0x01, 0x01, 0x03, 0x9D, 0xC8, 0x00]);
    }

    #[test]
    fn spec_ok_example() {
        // PROTOCOL.md §C.2.1 — D→H OK, tag=0x0001.
        // Expected on-wire: 03 80 01 03 F7 C4 00
        let mut wire = [0u8; MAX_WIRE_FRAME];
        let n = encode_frame(0x80, 0x0001, &[], &mut wire).unwrap();
        assert_eq!(&wire[..n], &[0x03, 0x80, 0x01, 0x03, 0xF7, 0xC4, 0x00]);
    }
}