use super::checksum;
use crate::error::NmeaError;
use crate::string::is_valid_armor_byte;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Channel {
A,
B,
Other(u8),
}
impl Channel {
fn from_field(s: &str) -> Self {
match s.as_bytes() {
[b'A'] => Self::A,
[b'B'] => Self::B,
[b] => Self::Other(*b),
_ => Self::Other(0),
}
}
#[must_use]
pub const fn to_byte(self) -> u8 {
match self {
Self::A => b'A',
Self::B => b'B',
Self::Other(b) => b,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Sentence<'a> {
pub is_own_ship: bool,
pub fragment_count: u8,
pub fragment_number: u8,
pub seq_id: Option<u8>,
pub channel: Channel,
pub payload: &'a [u8],
pub fill_bits: u8,
}
impl<'a> Sentence<'a> {
pub fn parse(line: &'a str) -> Result<Self, NmeaError> {
let line = line.trim();
let line = line
.bytes()
.position(|b| b == b'!' || b == b'$')
.and_then(|pos| line.get(pos..))
.unwrap_or(line);
let body = line
.strip_prefix('!')
.or_else(|| line.strip_prefix('$'))
.ok_or(NmeaError::MalformedSentence)?;
let (body, after_star) = body.split_once('*').ok_or(NmeaError::MalformedSentence)?;
let checksum_str = after_star.get(..2).ok_or(NmeaError::MalformedSentence)?;
let expected =
u8::from_str_radix(checksum_str, 16).map_err(|_| NmeaError::MalformedSentence)?;
let actual = checksum::compute(body.as_bytes());
if expected != actual {
return Err(NmeaError::ChecksumMismatch { expected, actual });
}
let mut fields = body.split(',');
let formatter = fields.next().ok_or(NmeaError::FieldCountMismatch)?;
let is_own_ship = if formatter.ends_with("VDO") {
true
} else if formatter.ends_with("VDM") {
false
} else {
return Err(NmeaError::UnsupportedFormatter);
};
let fragment_count = parse_u8(fields.next())?;
let fragment_number = parse_u8(fields.next())?;
if fragment_count == 0 || fragment_number == 0 || fragment_number > fragment_count {
return Err(NmeaError::InvalidFragmentFields);
}
let seq_id_str = fields.next().ok_or(NmeaError::FieldCountMismatch)?;
let seq_id = if seq_id_str.is_empty() {
None
} else {
Some(parse_u8(Some(seq_id_str))?)
};
let channel = Channel::from_field(fields.next().ok_or(NmeaError::FieldCountMismatch)?);
let payload_str = fields.next().ok_or(NmeaError::FieldCountMismatch)?;
let fill_bits_str = fields.next().ok_or(NmeaError::FieldCountMismatch)?;
if fields.next().is_some() {
return Err(NmeaError::FieldCountMismatch);
}
let fill_bits: u8 = fill_bits_str
.parse()
.map_err(|_| NmeaError::InvalidFillBits)?;
if fill_bits > 5 {
return Err(NmeaError::InvalidFillBits);
}
let payload = payload_str.as_bytes();
if payload.is_empty() || !payload.iter().all(|&b| is_valid_armor_byte(b)) {
return Err(NmeaError::InvalidArmorChar);
}
Ok(Self {
is_own_ship,
fragment_count,
fragment_number,
seq_id,
channel,
payload,
fill_bits,
})
}
pub fn format<'b>(&self, buf: &'b mut [u8]) -> Result<&'b [u8], NmeaError> {
if self.fill_bits > 5 {
return Err(NmeaError::InvalidFillBits);
}
let mut pos = 0;
push_byte(buf, &mut pos, b'!')?;
let body_start = pos;
push_bytes(
buf,
&mut pos,
if self.is_own_ship { b"AIVDO" } else { b"AIVDM" },
)?;
push_byte(buf, &mut pos, b',')?;
push_decimal(buf, &mut pos, self.fragment_count)?;
push_byte(buf, &mut pos, b',')?;
push_decimal(buf, &mut pos, self.fragment_number)?;
push_byte(buf, &mut pos, b',')?;
if let Some(seq_id) = self.seq_id {
push_decimal(buf, &mut pos, seq_id)?;
}
push_byte(buf, &mut pos, b',')?;
push_byte(buf, &mut pos, self.channel.to_byte())?;
push_byte(buf, &mut pos, b',')?;
push_bytes(buf, &mut pos, self.payload)?;
push_byte(buf, &mut pos, b',')?;
push_decimal(buf, &mut pos, self.fill_bits)?;
let body_end = pos;
let checksum = checksum::compute(&buf[body_start..body_end]);
push_byte(buf, &mut pos, b'*')?;
push_hex_byte(buf, &mut pos, checksum)?;
Ok(&buf[..pos])
}
}
fn parse_u8(field: Option<&str>) -> Result<u8, NmeaError> {
field
.ok_or(NmeaError::FieldCountMismatch)?
.parse()
.map_err(|_| NmeaError::MalformedSentence)
}
fn push_byte(buf: &mut [u8], pos: &mut usize, b: u8) -> Result<(), NmeaError> {
let slot = buf.get_mut(*pos).ok_or(NmeaError::BufferTooSmall)?;
*slot = b;
*pos += 1;
Ok(())
}
fn push_bytes(buf: &mut [u8], pos: &mut usize, bytes: &[u8]) -> Result<(), NmeaError> {
let end = pos
.checked_add(bytes.len())
.ok_or(NmeaError::BufferTooSmall)?;
let dst = buf.get_mut(*pos..end).ok_or(NmeaError::BufferTooSmall)?;
dst.copy_from_slice(bytes);
*pos = end;
Ok(())
}
fn push_decimal(buf: &mut [u8], pos: &mut usize, mut v: u8) -> Result<(), NmeaError> {
let mut digits = [0u8; 3];
let mut n = 0;
loop {
digits[n] = b'0' + (v % 10);
v /= 10;
n += 1;
if v == 0 {
break;
}
}
for i in (0..n).rev() {
push_byte(buf, pos, digits[i])?;
}
Ok(())
}
fn push_hex_byte(buf: &mut [u8], pos: &mut usize, v: u8) -> Result<(), NmeaError> {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
push_byte(buf, pos, HEX[(v >> 4) as usize])?;
push_byte(buf, pos, HEX[(v & 0x0F) as usize])?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
const GOOD: &str = "!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C";
#[test]
fn parses_single_fragment_sentence() {
let s = Sentence::parse(GOOD).unwrap();
assert!(!s.is_own_ship);
assert_eq!(s.fragment_count, 1);
assert_eq!(s.fragment_number, 1);
assert_eq!(s.seq_id, None);
assert_eq!(s.channel, Channel::B);
assert_eq!(s.fill_bits, 0);
assert_eq!(s.payload, b"15M67FC000G?ufbE`FepT@3n00Sa");
}
#[test]
fn strips_leading_bytes_before_nmea_start() {
let with_prefix = "XYZ!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C";
let s = Sentence::parse(with_prefix).unwrap();
assert_eq!(s.payload, b"15M67FC000G?ufbE`FepT@3n00Sa");
assert!(!s.is_own_ship);
}
#[test]
fn accepts_trailing_vendor_metadata_after_checksum() {
let with_metadata = "!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C,raishub,1342569600";
let s = Sentence::parse(with_metadata).unwrap();
assert_eq!(s.payload, b"15M67FC000G?ufbE`FepT@3n00Sa");
}
#[test]
fn rejects_bad_checksum() {
let bad = "!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*00";
assert!(matches!(
Sentence::parse(bad),
Err(NmeaError::ChecksumMismatch { .. })
));
}
#[test]
fn rejects_unsupported_formatter() {
let bad = "!GPGGA,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C";
assert!(matches!(
Sentence::parse(bad),
Err(NmeaError::UnsupportedFormatter | NmeaError::ChecksumMismatch { .. })
));
}
#[test]
fn rejects_missing_fields() {
let bad = "!AIVDM,1,1*57";
assert!(matches!(
Sentence::parse(bad),
Err(NmeaError::FieldCountMismatch)
));
}
#[test]
fn rejects_invalid_fragment_fields() {
for line in [
"!AIVDM,0,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5D",
"!AIVDM,1,0,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5D",
"!AIVDM,1,2,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5F",
] {
assert!(matches!(
Sentence::parse(line),
Err(NmeaError::InvalidFragmentFields)
));
}
}
#[test]
fn recognizes_own_ship_sentences() {
let line = "!AIVDO,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5E";
let s = Sentence::parse(line).unwrap();
assert!(s.is_own_ship);
}
#[test]
fn format_round_trips_a_parsed_sentence() {
let original = Sentence::parse(GOOD).unwrap();
let mut buf = [0u8; 64];
let formatted = original.format(&mut buf).unwrap();
assert_eq!(formatted, GOOD.as_bytes());
let reparsed = Sentence::parse(core::str::from_utf8(formatted).unwrap()).unwrap();
assert_eq!(reparsed, original);
}
#[test]
fn format_computes_a_fresh_checksum() {
let s = Sentence {
is_own_ship: true,
fragment_count: 2,
fragment_number: 1,
seq_id: Some(7),
channel: Channel::A,
payload: b"15M67FC000",
fill_bits: 0,
};
let mut buf = [0u8; 64];
let formatted = s.format(&mut buf).unwrap();
assert_eq!(formatted, b"!AIVDO,2,1,7,A,15M67FC000,0*6D");
let reparsed = Sentence::parse(core::str::from_utf8(formatted).unwrap()).unwrap();
assert_eq!(reparsed, s);
}
#[test]
fn format_reports_buffer_too_small() {
let s = Sentence::parse(GOOD).unwrap();
let mut buf = [0u8; 4];
assert_eq!(s.format(&mut buf), Err(NmeaError::BufferTooSmall));
}
#[test]
fn format_rejects_invalid_fill_bits() {
let mut s = Sentence::parse(GOOD).unwrap();
s.fill_bits = 6;
let mut buf = [0u8; 64];
assert_eq!(s.format(&mut buf), Err(NmeaError::InvalidFillBits));
}
proptest! {
#[test]
fn arbitrary_lines_never_panic(bytes in prop::collection::vec(any::<u8>(), 0..512)) {
if let Ok(line) = core::str::from_utf8(&bytes) {
let _ = Sentence::parse(line);
}
}
}
}