use crate::format::{Diagnostics, Parsed, RecordRef, Warning, WarningKind};
use crate::validate::{self, FieldError};
use crate::GnssSystem;
use super::{
Gga, GgaQuality, Gll, Gsa, GsaFixMode, GsaSelectionMode, Gst, Gsv, GsvSatellite,
NmeaCoordinate, NmeaDate, NmeaError, NmeaSatNumber, NmeaSignalId, NmeaTalker, NmeaTime, Rmc,
RmcStatus, Vtg, Zda,
};
#[derive(Debug, Clone, PartialEq)]
pub struct NmeaSentence {
pub talker: NmeaTalker,
pub body: NmeaBody,
}
#[derive(Debug, Clone, PartialEq)]
pub enum NmeaBody {
Gga(Gga),
Rmc(Rmc),
Gsa(Gsa),
Gsv(Gsv),
Gst(Gst),
Vtg(Vtg),
Gll(Gll),
Zda(Zda),
}
pub(crate) struct FramedSentence<'a> {
pub delimiter: u8,
pub body: &'a str,
pub diagnostics: Diagnostics,
}
pub(crate) fn checksum_body(body: &str) -> u8 {
body.bytes().fold(0, |acc, byte| acc ^ byte)
}
pub(crate) fn frame_sentence(line: &str) -> Result<FramedSentence<'_>, NmeaError> {
let mut diagnostics = Diagnostics::new();
let trimmed = line.trim_end_matches(['\r', '\n']);
let start =
trimmed
.bytes()
.position(|b| b == b'$' || b == b'!')
.ok_or(NmeaError::NotFramed {
reason: "no NMEA start delimiter",
})?;
if start > 0 {
diagnostics.push_warning(Warning {
at: RecordRef::default(),
kind: WarningKind::Mismatch,
});
}
let sentence = &trimmed[start..];
if sentence.len() > 1024 {
return Err(NmeaError::NotFramed {
reason: "sentence over length cap",
});
}
if !sentence.is_ascii() {
return Err(NmeaError::NotFramed {
reason: "non-ASCII byte",
});
}
let delimiter = sentence.as_bytes()[0];
let rest = &sentence[1..];
let (body, checksum) = if let Some(star) = rest.rfind('*') {
let checksum_token = rest.get(star + 1..star + 3).unwrap_or("");
if checksum_token.len() != 2 || !checksum_token.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(NmeaError::NotFramed {
reason: "malformed checksum",
});
}
let trailing = &rest[star + 3..];
if !trailing
.bytes()
.all(|b| b == b' ' || b == b'\r' || b == b'\n')
{
diagnostics.push_warning(Warning {
at: RecordRef::default(),
kind: WarningKind::Mismatch,
});
}
let stated = u8::from_str_radix(checksum_token, 16).map_err(|_| NmeaError::NotFramed {
reason: "malformed checksum",
})?;
(&rest[..star], Some(stated))
} else {
diagnostics.push_warning(Warning {
at: RecordRef::default(),
kind: WarningKind::MissingMetadata,
});
(rest, None)
};
if let Some(stated) = checksum {
let computed = checksum_body(body);
if computed != stated {
return Err(NmeaError::ChecksumMismatch { computed, stated });
}
}
Ok(FramedSentence {
delimiter,
body,
diagnostics,
})
}
pub(crate) fn parse_framed(framed: FramedSentence<'_>) -> Result<Parsed<NmeaSentence>, NmeaError> {
if framed.delimiter == b'!' {
return Err(NmeaError::UnsupportedType {
address: "encapsulated sentence".to_string(),
});
}
let mut diagnostics = framed.diagnostics;
let mut parts = framed.body.split(',');
let address = parts.next().unwrap_or_default();
if address.starts_with('P') {
return Err(NmeaError::Proprietary {
address: address.to_string(),
});
}
if address.len() != 5 {
return Err(NmeaError::UnsupportedType {
address: address.to_string(),
});
}
let talker = NmeaTalker::parse(&address[..2]);
let sentence_type = &address[2..];
let fields: Vec<&str> = parts.collect();
let body = match sentence_type {
"GGA" => NmeaBody::Gga(parse_gga(&fields)?),
"RMC" => NmeaBody::Rmc(parse_rmc(&fields)?),
"GSA" => NmeaBody::Gsa(parse_gsa(talker, &fields, &mut diagnostics)?),
"GSV" => NmeaBody::Gsv(parse_gsv(talker, &fields, &mut diagnostics)?),
"GST" => NmeaBody::Gst(parse_gst(&fields)?),
"VTG" => NmeaBody::Vtg(parse_vtg(&fields)?),
"GLL" => NmeaBody::Gll(parse_gll(&fields)?),
"ZDA" => NmeaBody::Zda(parse_zda(&fields)?),
_ => {
return Err(NmeaError::UnsupportedType {
address: address.to_string(),
})
}
};
Ok(Parsed::new(NmeaSentence { talker, body }, diagnostics))
}
fn parse_gga(fields: &[&str]) -> Result<Gga, FieldError> {
Ok(Gga {
time: parse_opt_time(fields.first().copied())?,
latitude: parse_opt_coordinate(fields.get(1).copied(), fields.get(2).copied(), true)?,
longitude: parse_opt_coordinate(fields.get(3).copied(), fields.get(4).copied(), false)?,
quality: parse_opt_quality(fields.get(5).copied())?,
satellites_used: parse_opt_u8_range(fields.get(6).copied(), "satellites used", 0, 99)?,
hdop: parse_opt_f64(fields.get(7).copied(), "hdop")?,
altitude_msl_m: parse_opt_unit_f64(
fields.get(8).copied(),
fields.get(9).copied(),
"altitude msl",
)?,
geoid_separation_m: parse_opt_unit_f64(
fields.get(10).copied(),
fields.get(11).copied(),
"geoid separation",
)?,
differential_age_s: parse_opt_f64(fields.get(12).copied(), "differential age")?,
differential_station_id: parse_opt_u16_range(
fields.get(13).copied(),
"differential station id",
0,
9999,
)?,
})
}
fn parse_rmc(fields: &[&str]) -> Result<Rmc, FieldError> {
let magnetic = parse_opt_signed_pair(
fields.get(9).copied(),
fields.get(10).copied(),
"magnetic variation",
"EW",
)?;
Ok(Rmc {
time: parse_opt_time(fields.first().copied())?,
status: parse_opt_rmc_status(fields.get(1).copied())?,
latitude: parse_opt_coordinate(fields.get(2).copied(), fields.get(3).copied(), true)?,
longitude: parse_opt_coordinate(fields.get(4).copied(), fields.get(5).copied(), false)?,
speed_over_ground_kn: parse_opt_f64(fields.get(6).copied(), "speed over ground")?,
course_over_ground_deg: parse_opt_f64(fields.get(7).copied(), "course over ground")?,
date: parse_opt_date_rmc(fields.get(8).copied())?,
magnetic_variation_deg: magnetic,
faa_mode: parse_opt_char(fields.get(11).copied(), "faa mode")?,
navigational_status: parse_opt_char(fields.get(12).copied(), "navigation status")?,
})
}
fn parse_gsa(
talker: NmeaTalker,
fields: &[&str],
diagnostics: &mut Diagnostics,
) -> Result<Gsa, FieldError> {
let system_id = parse_opt_u8_range(fields.get(17).copied(), "system id", 0, 9)?;
let system = match system_id {
Some(1) => Some(GnssSystem::Gps),
Some(2) => Some(GnssSystem::Glonass),
Some(3) => Some(GnssSystem::Galileo),
Some(4) => Some(GnssSystem::BeiDou),
Some(5) => Some(GnssSystem::Qzss),
Some(6) => Some(GnssSystem::Navic),
Some(_) => {
diagnostics.push_warning(Warning {
at: RecordRef::default(),
kind: WarningKind::Degraded,
});
None
}
None => talker.system(),
};
let mut satellites = Vec::new();
for field in fields.iter().skip(2).take(12) {
if let Some(sat) = parse_opt_sat_number(Some(*field), system, diagnostics)? {
satellites.push(sat);
}
}
Ok(Gsa {
selection_mode: parse_opt_gsa_selection(fields.first().copied())?,
fix_mode: parse_opt_gsa_fix(fields.get(1).copied())?,
satellites,
pdop: parse_opt_f64(fields.get(14).copied(), "pdop")?,
hdop: parse_opt_f64(fields.get(15).copied(), "hdop")?,
vdop: parse_opt_f64(fields.get(16).copied(), "vdop")?,
system_id,
system,
})
}
fn parse_gsv(
talker: NmeaTalker,
fields: &[&str],
diagnostics: &mut Diagnostics,
) -> Result<Gsv, FieldError> {
let total_messages = parse_required_u8_range(fields.first().copied(), "gsv total", 1, 15)?;
let message_number = parse_required_u8_range(
fields.get(1).copied(),
"gsv message number",
1,
total_messages,
)?;
let satellites_in_view =
parse_opt_u16_range(fields.get(2).copied(), "satellites in view", 0, 999)?;
let tail = if fields.len() > 3 { &fields[3..] } else { &[] };
let rem = tail.len() % 4;
let (sat_fields, signal) = if rem == 1 {
let signal = parse_opt_signal_id(tail.last().copied(), talker.system(), diagnostics)?;
(&tail[..tail.len() - 1], signal)
} else {
if rem == 2 || rem == 3 {
diagnostics.push_warning(Warning {
at: RecordRef::default(),
kind: WarningKind::Mismatch,
});
}
(tail, None)
};
let mut satellites = Vec::new();
for chunk in sat_fields.chunks(4) {
let sat_number =
parse_opt_sat_number(chunk.first().copied(), talker.system(), diagnostics)?;
let elevation_deg = parse_opt_i16_range(chunk.get(1).copied(), "elevation", -90, 90)?;
let azimuth_deg = parse_opt_u16_range(chunk.get(2).copied(), "azimuth", 0, 359)?;
let cn0_db_hz = parse_opt_u8_range(chunk.get(3).copied(), "cn0", 0, 99)?;
satellites.push(GsvSatellite {
sat_number,
elevation_deg,
azimuth_deg,
cn0_db_hz,
});
}
Ok(Gsv {
total_messages,
message_number,
satellites_in_view,
satellites,
signal,
})
}
fn parse_gst(fields: &[&str]) -> Result<Gst, FieldError> {
Ok(Gst {
time: parse_opt_time(fields.first().copied())?,
rms_range_residual_m: parse_opt_f64(fields.get(1).copied(), "range residual rms")?,
semi_major_error_m: parse_opt_f64(fields.get(2).copied(), "semi major error")?,
semi_minor_error_m: parse_opt_f64(fields.get(3).copied(), "semi minor error")?,
orientation_deg: parse_opt_f64(fields.get(4).copied(), "error orientation")?,
latitude_sigma_m: parse_opt_f64(fields.get(5).copied(), "latitude sigma")?,
longitude_sigma_m: parse_opt_f64(fields.get(6).copied(), "longitude sigma")?,
altitude_sigma_m: parse_opt_f64(fields.get(7).copied(), "altitude sigma")?,
})
}
fn parse_vtg(fields: &[&str]) -> Result<Vtg, FieldError> {
Ok(Vtg {
course_true_deg: parse_opt_tagged_f64(
fields.first().copied(),
fields.get(1).copied(),
"course true",
"T",
)?,
course_magnetic_deg: parse_opt_tagged_f64(
fields.get(2).copied(),
fields.get(3).copied(),
"course magnetic",
"M",
)?,
speed_kn: parse_opt_tagged_f64(
fields.get(4).copied(),
fields.get(5).copied(),
"speed knots",
"N",
)?,
speed_kmh: parse_opt_tagged_f64(
fields.get(6).copied(),
fields.get(7).copied(),
"speed kmh",
"K",
)?,
faa_mode: parse_opt_char(fields.get(8).copied(), "faa mode")?,
})
}
fn parse_gll(fields: &[&str]) -> Result<Gll, FieldError> {
Ok(Gll {
latitude: parse_opt_coordinate(fields.first().copied(), fields.get(1).copied(), true)?,
longitude: parse_opt_coordinate(fields.get(2).copied(), fields.get(3).copied(), false)?,
time: parse_opt_time(fields.get(4).copied())?,
status: parse_opt_rmc_status(fields.get(5).copied())?,
faa_mode: parse_opt_char(fields.get(6).copied(), "faa mode")?,
})
}
fn parse_zda(fields: &[&str]) -> Result<Zda, FieldError> {
let day = parse_opt_u8_range(fields.get(1).copied(), "zda day", 1, 31)?;
let month = parse_opt_u8_range(fields.get(2).copied(), "zda month", 1, 12)?;
let year = parse_opt_u16_range(fields.get(3).copied(), "zda year", 0, 9999)?;
let date = match (year, month, day) {
(Some(year), Some(month), Some(day)) => Some(NmeaDate::new(year, month, day)?),
(None, None, None) => None,
_ => return Err(FieldError::Missing { field: "zda date" }),
};
Ok(Zda {
time: parse_opt_time(fields.first().copied())?,
date,
local_zone_hours: parse_opt_i8_range(fields.get(4).copied(), "zone hours", -13, 13)?,
local_zone_minutes: parse_opt_u8_range(fields.get(5).copied(), "zone minutes", 0, 59)?,
})
}
fn parse_opt_time(token: Option<&str>) -> Result<Option<NmeaTime>, FieldError> {
match token.unwrap_or("").trim() {
"" => Ok(None),
token => NmeaTime::parse(token).map(Some),
}
}
fn parse_opt_date_rmc(token: Option<&str>) -> Result<Option<NmeaDate>, FieldError> {
match token.unwrap_or("").trim() {
"" => Ok(None),
token => NmeaDate::parse_rmc(token).map(Some),
}
}
fn parse_opt_coordinate(
value: Option<&str>,
hemisphere: Option<&str>,
is_latitude: bool,
) -> Result<Option<NmeaCoordinate>, FieldError> {
let value = value.unwrap_or("").trim();
let hemisphere = hemisphere.unwrap_or("").trim();
match (value.is_empty(), hemisphere.is_empty()) {
(true, true) => Ok(None),
(false, false) => NmeaCoordinate::parse(value, hemisphere, is_latitude).map(Some),
_ => Err(FieldError::Missing {
field: if is_latitude {
"latitude pair"
} else {
"longitude pair"
},
}),
}
}
fn parse_opt_quality(token: Option<&str>) -> Result<Option<GgaQuality>, FieldError> {
match token.unwrap_or("").trim() {
"" => Ok(None),
token => GgaQuality::parse(token).map(Some),
}
}
fn parse_opt_rmc_status(token: Option<&str>) -> Result<Option<RmcStatus>, FieldError> {
match parse_opt_char(token, "rmc status")? {
None => Ok(None),
Some('A') => Ok(Some(RmcStatus::Valid)),
Some('V') => Ok(Some(RmcStatus::Warning)),
Some(ch) => Ok(Some(RmcStatus::Other(ch))),
}
}
fn parse_opt_gsa_selection(token: Option<&str>) -> Result<Option<GsaSelectionMode>, FieldError> {
match parse_opt_char(token, "gsa selection")? {
None => Ok(None),
Some('M') => Ok(Some(GsaSelectionMode::Manual)),
Some('A') => Ok(Some(GsaSelectionMode::Automatic)),
Some(ch) => Ok(Some(GsaSelectionMode::Other(ch))),
}
}
fn parse_opt_gsa_fix(token: Option<&str>) -> Result<Option<GsaFixMode>, FieldError> {
match token.unwrap_or("").trim() {
"" => Ok(None),
token => {
let value = validate::strict_int::<u8>(token, "gsa fix mode")?;
Ok(Some(match value {
1 => GsaFixMode::None,
2 => GsaFixMode::TwoD,
3 => GsaFixMode::ThreeD,
other => GsaFixMode::Other(other),
}))
}
}
}
fn parse_opt_char(token: Option<&str>, field: &'static str) -> Result<Option<char>, FieldError> {
let token = token.unwrap_or("").trim();
if token.is_empty() {
return Ok(None);
}
let mut chars = token.chars();
let Some(ch) = chars.next() else {
return Ok(None);
};
if chars.next().is_some() {
return Err(FieldError::IntParse {
field,
value: token.to_string(),
});
}
Ok(Some(ch))
}
fn parse_opt_f64(token: Option<&str>, field: &'static str) -> Result<Option<f64>, FieldError> {
match token.unwrap_or("").trim() {
"" => Ok(None),
token => validate::strict_f64(token, field).map(Some),
}
}
fn parse_opt_tagged_f64(
value: Option<&str>,
unit: Option<&str>,
field: &'static str,
expected_unit: &str,
) -> Result<Option<f64>, FieldError> {
let value = value.unwrap_or("").trim();
let unit = unit.unwrap_or("").trim();
if value.is_empty() {
return Ok(None);
}
if unit != expected_unit {
return Err(FieldError::OutOfRange {
field: "unit",
min: 0.0,
max: 0.0,
upper_inclusive: true,
});
}
validate::strict_f64(value, field).map(Some)
}
fn parse_opt_signed_pair(
value: Option<&str>,
direction: Option<&str>,
field: &'static str,
valid_dirs: &str,
) -> Result<Option<f64>, FieldError> {
let Some(mut value) = parse_opt_f64(value, field)? else {
return Ok(None);
};
let direction = direction.unwrap_or("").trim();
if direction.len() != 1 || !valid_dirs.contains(direction) {
return Err(FieldError::OutOfRange {
field: "direction",
min: 0.0,
max: 0.0,
upper_inclusive: true,
});
}
if direction == "W" || direction == "S" {
value = -value;
}
Ok(Some(value))
}
fn parse_opt_unit_f64(
value: Option<&str>,
unit: Option<&str>,
field: &'static str,
) -> Result<Option<f64>, FieldError> {
let value = value.unwrap_or("").trim();
let unit = unit.unwrap_or("").trim();
if value.is_empty() {
if unit.is_empty() {
return Ok(None);
}
return Err(FieldError::Missing { field });
}
if unit != "M" {
return Err(FieldError::OutOfRange {
field: "unit",
min: 0.0,
max: 0.0,
upper_inclusive: true,
});
}
validate::strict_f64(value, field).map(Some)
}
fn parse_required_u8_range(
token: Option<&str>,
field: &'static str,
min: u8,
max: u8,
) -> Result<u8, FieldError> {
parse_opt_u8_range(token, field, min, max)?.ok_or(FieldError::Missing { field })
}
fn parse_opt_u8_range(
token: Option<&str>,
field: &'static str,
min: u8,
max: u8,
) -> Result<Option<u8>, FieldError> {
match token.unwrap_or("").trim() {
"" => Ok(None),
token => {
let value = validate::strict_int::<u8>(token, field)?;
if value < min || value > max {
Err(FieldError::OutOfRange {
field,
min: f64::from(min),
max: f64::from(max),
upper_inclusive: true,
})
} else {
Ok(Some(value))
}
}
}
}
fn parse_opt_i8_range(
token: Option<&str>,
field: &'static str,
min: i8,
max: i8,
) -> Result<Option<i8>, FieldError> {
match token.unwrap_or("").trim() {
"" => Ok(None),
token => {
let value = validate::strict_int::<i8>(token, field)?;
if value < min || value > max {
Err(FieldError::OutOfRange {
field,
min: f64::from(min),
max: f64::from(max),
upper_inclusive: true,
})
} else {
Ok(Some(value))
}
}
}
}
fn parse_opt_i16_range(
token: Option<&str>,
field: &'static str,
min: i16,
max: i16,
) -> Result<Option<i16>, FieldError> {
match token.unwrap_or("").trim() {
"" => Ok(None),
token => {
let value = validate::strict_int::<i16>(token, field)?;
if value < min || value > max {
Err(FieldError::OutOfRange {
field,
min: f64::from(min),
max: f64::from(max),
upper_inclusive: true,
})
} else {
Ok(Some(value))
}
}
}
}
fn parse_opt_u16_range(
token: Option<&str>,
field: &'static str,
min: u16,
max: u16,
) -> Result<Option<u16>, FieldError> {
match token.unwrap_or("").trim() {
"" => Ok(None),
token => {
let value = validate::strict_int::<u16>(token, field)?;
if value < min || value > max {
Err(FieldError::OutOfRange {
field,
min: f64::from(min),
max: f64::from(max),
upper_inclusive: true,
})
} else {
Ok(Some(value))
}
}
}
}
fn parse_opt_sat_number(
token: Option<&str>,
context: Option<GnssSystem>,
diagnostics: &mut Diagnostics,
) -> Result<Option<NmeaSatNumber>, FieldError> {
let token = token.unwrap_or("").trim();
if token.is_empty() {
return Ok(None);
}
let raw = validate::strict_int::<u16>(token, "satellite number")?;
let resolved = super::fields::resolve_sat_number(context, raw);
if resolved.is_none() {
diagnostics.push_warning(Warning {
at: RecordRef::default(),
kind: WarningKind::Degraded,
});
}
Ok(Some(NmeaSatNumber { raw, resolved }))
}
fn parse_opt_signal_id(
token: Option<&str>,
system: Option<GnssSystem>,
diagnostics: &mut Diagnostics,
) -> Result<Option<NmeaSignalId>, FieldError> {
let token = token.unwrap_or("").trim();
if token.is_empty() {
return Ok(None);
}
let id = if token.len() == 1 {
u8::from_str_radix(token, 16).map_err(|_| FieldError::IntParse {
field: "signal id",
value: token.to_string(),
})?
} else {
validate::strict_int::<u8>(token, "signal id")?
};
let signal = NmeaSignalId { system, id };
if id > 15 || signal.carrier_band().is_none() {
diagnostics.push_warning(Warning {
at: RecordRef::default(),
kind: WarningKind::Degraded,
});
}
Ok(Some(signal))
}