libexail 0.1.0

A rust library for communicating with Exail devices through their binary protocol
Documentation
//! Stateful and one-shot parsers for Exail binary protocol frames.

use crate::{
    blocks::{
        InputDataFrame, Message, NavigationDataFrame, extended::parse_extended_navigation_blocks,
        external::parse_external_blocks, navigation::parse_navigation_blocks,
    },
    framing,
    header::{AnswerHeader, CommandHeader, InputHeader, OutputHeader, SyncType},
};
use std::io::Cursor;

enum ParseError {
    IncompleteData,
    InvalidSync,
    InvalidChecksum,
    InvalidPayload,
}

/// Parse a single frame from input. Returns the message and number of bytes consumed.
fn parse_frame(input: &[u8]) -> Result<(Message, usize), ParseError> {
    if input.len() < 3 {
        return Err(ParseError::IncompleteData);
    }

    let sync_type = framing::is_sync(input[0], input[1]).ok_or(ParseError::InvalidSync)?;
    let version = input[2];

    match sync_type {
        SyncType::Command => {
            if input.len() < CommandHeader::HEADER_SIZE {
                return Err(ParseError::IncompleteData);
            }
            let header =
                CommandHeader::parse(&input[2..]).map_err(|_| ParseError::InvalidPayload)?;
            let total = header.total_size as usize;
            if input.len() < total {
                return Err(ParseError::IncompleteData);
            }
            let frame = &input[..total];
            if framing::validate_checksum(frame) != Some(true) {
                return Err(ParseError::InvalidChecksum);
            }
            let body = input[CommandHeader::HEADER_SIZE..total - 4].to_vec();
            Ok((Message::Command(body), total))
        }
        SyncType::Answer => {
            if input.len() < AnswerHeader::HEADER_SIZE {
                return Err(ParseError::IncompleteData);
            }
            let header =
                AnswerHeader::parse(&input[2..]).map_err(|_| ParseError::InvalidPayload)?;
            let total = header.total_size as usize;
            if input.len() < total {
                return Err(ParseError::IncompleteData);
            }
            let frame = &input[..total];
            if framing::validate_checksum(frame) != Some(true) {
                return Err(ParseError::InvalidChecksum);
            }
            let body = input[AnswerHeader::HEADER_SIZE..total - 4].to_vec();
            Ok((Message::Answer(body), total))
        }
        SyncType::NavData => {
            let header_size =
                OutputHeader::header_size(version).map_err(|_| ParseError::InvalidPayload)?;
            if input.len() < header_size {
                return Err(ParseError::IncompleteData);
            }

            // Try output header first (nav_bitmask != 0 means output)
            let output_header =
                OutputHeader::parse(&input[2..]).map_err(|_| ParseError::InvalidPayload)?;
            let total = output_header.total_size as usize;
            if input.len() < total {
                return Err(ParseError::IncompleteData);
            }
            let frame = &input[..total];
            if framing::validate_checksum(frame) != Some(true) {
                return Err(ParseError::InvalidChecksum);
            }

            let body = &input[header_size..total - 4];

            if output_header.nav_bitmask != 0 || output_header.extended_nav_bitmask != 0 {
                // Output navigation data frame
                let cursor = &mut Cursor::new(body);
                let navigation = parse_navigation_blocks(
                    output_header.nav_bitmask,
                    output_header.version,
                    cursor,
                )
                .map_err(|_| ParseError::InvalidPayload)?;
                let extended_navigation =
                    parse_extended_navigation_blocks(output_header.extended_nav_bitmask, cursor)
                        .map_err(|_| ParseError::InvalidPayload)?;
                let external = parse_external_blocks(output_header.external_bitmask, cursor)
                    .map_err(|_| ParseError::InvalidPayload)?;

                Ok((
                    Message::NavigationData(NavigationDataFrame {
                        header: output_header,
                        navigation,
                        extended_navigation,
                        external,
                    }),
                    total,
                ))
            } else {
                // Input sensor data frame
                let input_header =
                    InputHeader::parse(&input[2..]).map_err(|_| ParseError::InvalidPayload)?;
                let cursor = &mut Cursor::new(body);
                let external = parse_external_blocks(input_header.external_bitmask, cursor)
                    .map_err(|_| ParseError::InvalidPayload)?;

                Ok((
                    Message::InputData(InputDataFrame {
                        header: input_header,
                        external,
                    }),
                    total,
                ))
            }
        }
    }
}

/// Error type for datagram parsing.
#[derive(Debug)]
pub enum DatagramError {
    IncompleteData,
    InvalidSync,
    InvalidChecksum,
    InvalidPayload,
}

/// Parse a single Exail frame from a datagram. Expects the frame to
/// start at byte 0 — no scanning.
pub fn parse_datagram(datagram: &[u8]) -> Result<Message, DatagramError> {
    match parse_frame(datagram) {
        Ok((message, _len)) => Ok(message),
        Err(ParseError::IncompleteData) => Err(DatagramError::IncompleteData),
        Err(ParseError::InvalidSync) => Err(DatagramError::InvalidSync),
        Err(ParseError::InvalidChecksum) => Err(DatagramError::InvalidChecksum),
        Err(ParseError::InvalidPayload) => Err(DatagramError::InvalidPayload),
    }
}

/// Stateful stream parser for TCP or other byte-stream transports.
///
/// Buffers incoming bytes and scans for valid Exail frames. When a parse
/// attempt fails, advances by one byte and retries.
pub struct ExailParser {
    buf: Vec<u8>,
    buf_start: usize,
}

impl ExailParser {
    pub fn new() -> Self {
        Self {
            buf: Vec::new(),
            buf_start: 0,
        }
    }

    /// Feed bytes and attempt to parse a frame. Returns `None` if no
    /// complete frame is available yet. Call repeatedly with empty input
    /// to drain buffered frames.
    pub fn consume(&mut self, input: &[u8]) -> Option<Message> {
        self.buf.extend(input);

        loop {
            let available = &self.buf[self.buf_start..];
            if available.is_empty() {
                return None;
            }

            match parse_frame(available) {
                Ok((message, bytes_consumed)) => {
                    self.buf_start += bytes_consumed;
                    if self.buf_start > self.buf.len() / 2 {
                        self.buf.drain(0..self.buf_start);
                        self.buf_start = 0;
                    }
                    return Some(message);
                }
                Err(ParseError::IncompleteData) => {
                    return None;
                }
                Err(
                    ParseError::InvalidSync
                    | ParseError::InvalidChecksum
                    | ParseError::InvalidPayload,
                ) => {
                    self.buf_start += 1;
                    if self.buf_start >= self.buf.len() {
                        self.buf.clear();
                        self.buf_start = 0;
                        return None;
                    }
                }
            }
        }
    }

    /// Current buffered byte count.
    pub fn buffer_len(&self) -> usize {
        self.buf.len() - self.buf_start
    }

    /// Discard all buffered data.
    pub fn clear(&mut self) {
        self.buf.clear();
        self.buf_start = 0;
    }
}

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

#[cfg(test)]
mod tests {
    use crate::framing;
    use crate::parser::{DatagramError, ExailParser, parse_datagram};

    #[test]
    fn test_parser_incomplete() {
        let mut parser = ExailParser::new();
        assert!(parser.consume(&[b'I', b'X']).is_none());
        assert_eq!(parser.buffer_len(), 2);
    }

    #[test]
    fn test_parser_clear() {
        let mut parser = ExailParser::new();
        parser.consume(&[0x01, 0x02, 0x03]);
        parser.clear();
        assert_eq!(parser.buffer_len(), 0);
    }

    #[test]
    fn test_datagram_too_short() {
        assert!(matches!(
            parse_datagram(&[b'I']),
            Err(DatagramError::IncompleteData)
        ));
    }

    #[test]
    fn test_datagram_bad_sync() {
        assert!(matches!(
            parse_datagram(&[b'Z', b'Z', 0x03, 0x00, 0x00]),
            Err(DatagramError::InvalidSync)
        ));
    }

    /// Build a minimal command frame and round-trip it.
    #[test]
    fn test_command_round_trip() {
        let body = [0xAA, 0xBB];
        // Header: 'C' 'M' version=3 total_size (5 header + 2 body + 4 checksum = 11)
        let mut frame = Vec::new();
        frame.push(b'C');
        frame.push(b'M');
        frame.push(0x03);
        frame.extend_from_slice(&11u16.to_be_bytes());
        frame.extend_from_slice(&body);
        let checksum = framing::calculate_checksum(&frame);
        frame.extend_from_slice(&checksum.to_be_bytes());

        let msg = parse_datagram(&frame).unwrap();
        match msg {
            crate::blocks::Message::Command(data) => assert_eq!(data, body),
            other => panic!("expected Command, got {:?}", other),
        }
    }

    /// Build a minimal command frame and parse via the streaming parser.
    #[test]
    fn test_streaming_command() {
        let body = [0xCC];
        let mut frame = Vec::new();
        frame.push(b'C');
        frame.push(b'M');
        frame.push(0x03);
        frame.extend_from_slice(&10u16.to_be_bytes());
        frame.extend_from_slice(&body);
        let checksum = framing::calculate_checksum(&frame);
        frame.extend_from_slice(&checksum.to_be_bytes());

        let mut parser = ExailParser::new();
        let msg = parser.consume(&frame).expect("should parse command frame");
        match msg {
            crate::blocks::Message::Command(data) => assert_eq!(data, [0xCC]),
            other => panic!("expected Command, got {:?}", other),
        }
        assert_eq!(parser.buffer_len(), 0);
    }
}