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),
}
}
}
#[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 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())?;
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,
})
}
}
fn parse_u8(field: Option<&str>) -> Result<u8, NmeaError> {
field
.ok_or(NmeaError::FieldCountMismatch)?
.parse()
.map_err(|_| NmeaError::MalformedSentence)
}
#[cfg(test)]
mod tests {
use super::*;
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 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 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);
}
}