nmeasis 26.4.1

A memory-safe NMEA 0183 parser with a C FFI
Documentation
use crate::{
    encoder::{NmeaEncode, NmeaEncoderError},
    macros::{write_byte, write_str},
    message::{Message, NmeaMessageError},
    talker::Talker,
};

#[derive(Debug, thiserror::Error)]
pub enum NmeaSentenceError {
    #[error("Sentence is missing dollar sign")]
    MissingDollar,
    #[error("Sentence is missing comma")]
    MissingComma,
    #[error("Sentence is missing checksum")]
    MissingChecksum,
    #[error("Sentence checksum is invalid")]
    InvalidChecksum,
    #[error("Sentence checksum mismatch - expected: {expected}, got: {computed}")]
    ChecksumMismatch { expected: u8, computed: u8 },
    #[error("Sentence is not UTF8")]
    NotUtf8(#[from] core::str::Utf8Error),
    #[error("Sentence is not ASCII")]
    NotAscii,
    #[error("Sentence tag is not 5 characters long")]
    TagTooShort,
    #[error("Sentence is missing field")]
    MissingField,
    #[error("Message Error: {0}")]
    Message(#[from] NmeaMessageError),
}

#[derive(Debug)]
pub struct Sentence<'a> {
    pub talker: Talker,
    pub message: Message<'a>,
}

impl<'a> Sentence<'a> {
    /// Parse a sentence from the given slice. It does checksum validation before
    /// passing it on to the `Message` parser.
    pub(crate) fn parse(slice: &'a [u8]) -> Result<Self, NmeaSentenceError> {
        let slice = slice
            .strip_prefix(b"$")
            .ok_or(NmeaSentenceError::MissingDollar)?;

        if !slice.is_ascii() {
            return Err(NmeaSentenceError::NotAscii);
        }

        let (payload, checksum_part) = memchr::memrchr(b'*', slice)
            .map(|i| (&slice[..i], &slice[i + 1..]))
            .ok_or(NmeaSentenceError::MissingChecksum)?;

        let checksum_str = core::str::from_utf8(
            checksum_part
                .get(..2)
                .ok_or(NmeaSentenceError::InvalidChecksum)?,
        )?;
        let expected =
            u8::from_str_radix(checksum_str, 16).map_err(|_| NmeaSentenceError::InvalidChecksum)?;
        let computed = payload.iter().fold(0u8, |acc, &b| acc ^ b);
        if computed != expected {
            return Err(NmeaSentenceError::ChecksumMismatch { expected, computed });
        }

        let comma = memchr::memchr(b',', payload).ok_or(NmeaSentenceError::MissingComma)?;

        let payload_str = core::str::from_utf8(payload)?;
        let tag = &payload_str[..comma];

        if tag.starts_with('P') {
            return Ok(Self {
                talker: Talker::Proprietary,
                message: Message::Unknown(payload_str),
            });
        }

        if tag.len() < 3 {
            return Err(NmeaSentenceError::TagTooShort);
        }

        let talker = Talker::parse(&tag[..2]);
        let code = &tag[2..];
        let fields = &payload_str[comma + 1..];
        let message = Message::parse(code, fields)?;

        Ok(Self { talker, message })
    }

    pub fn encode(&self, buf: &mut [u8]) -> Result<usize, NmeaEncoderError> {
        // framing overhead: '$' + talker(2) + code(3) + ',' + '*' + checksum(2) + '\r\n'
        const OVERHEAD: usize = 1 + 2 + 3 + 1 + 1 + 2 + 2;

        if buf.len() < OVERHEAD + self.message.encoded_len() {
            return Err(NmeaEncoderError::SentenceTooLong);
        }

        let mut pos = 0;
        write_byte!(buf, pos, b'$');
        // TODO: Talker needs a as_str or to_str.
        write_str!(buf, pos, "GP");
        write_str!(buf, pos, self.message.code());
        write_byte!(buf, pos, b',');

        pos += self.message.encode(&mut buf[pos..]);
        let fields_end = pos;

        // checksum over everything between '$' and '*' exclusive
        let checksum = buf[1..fields_end].iter().fold(0u8, |acc, &b| acc ^ b);

        write_byte!(buf, pos, b'*');
        // write checksum as uppercase hex
        buf[pos] = b"0123456789ABCDEF"[(checksum >> 4) as usize];
        pos += 1;
        buf[pos] = b"0123456789ABCDEF"[(checksum & 0xF) as usize];
        pos += 1;
        write_byte!(buf, pos, b'\r');
        write_byte!(buf, pos, b'\n');

        Ok(pos)
    }
}