Skip to main content

sidereon_core/nmea/
mod.rs

1//! Sans-I/O NMEA 0183 sentence parsing and GGA writing.
2
3mod epoch;
4mod fields;
5mod sentence;
6#[cfg(test)]
7mod tests;
8mod write;
9
10pub use crate::format::{Diagnostics, Parsed, RecordRef, Skip, SkipReason, Warning, WarningKind};
11pub use crate::validate::FieldError;
12pub use epoch::{EpochSnapshot, GsaEntry, GsvGroup, NmeaAccumulator, NmeaChunkOutput};
13pub use fields::{
14    Gga, GgaQuality, Gll, Gsa, GsaFixMode, GsaSelectionMode, Gst, Gsv, GsvSatellite,
15    NmeaCoordinate, NmeaDate, NmeaSatNumber, NmeaSignalId, NmeaTalker, NmeaTime, Rmc, RmcStatus,
16    Vtg, Zda,
17};
18pub use sentence::{NmeaBody, NmeaSentence};
19pub use write::write_gga;
20
21#[derive(Debug, Clone, PartialEq, thiserror::Error)]
22pub enum NmeaError {
23    #[error("not an NMEA sentence: {reason}")]
24    NotFramed { reason: &'static str },
25    #[error("checksum mismatch: computed {computed:02X}, stated {stated:02X}")]
26    ChecksumMismatch { computed: u8, stated: u8 },
27    #[error("unsupported sentence type {address}")]
28    UnsupportedType { address: String },
29    #[error("proprietary sentence {address}")]
30    Proprietary { address: String },
31    #[error("malformed field: {0}")]
32    MalformedField(#[from] FieldError),
33    #[error("invalid input {field}: {reason}")]
34    InvalidInput {
35        field: &'static str,
36        reason: &'static str,
37    },
38}
39
40#[derive(Debug, Clone, PartialEq)]
41pub struct NmeaLog {
42    pub sentences: Vec<NmeaSentence>,
43}
44
45pub fn parse_sentence(line: &str) -> Result<Parsed<NmeaSentence>, NmeaError> {
46    sentence::parse_framed(sentence::frame_sentence(line)?)
47}
48
49pub fn parse_nmea(input: &[u8]) -> Parsed<NmeaLog> {
50    let mut diagnostics = Diagnostics::new();
51    let mut sentences = Vec::new();
52    for (index, line) in input.split(|b| *b == b'\n').enumerate() {
53        let line = line.strip_suffix(b"\r").unwrap_or(line);
54        if line.is_empty() {
55            continue;
56        }
57        let line_number = index + 1;
58        match std::str::from_utf8(line) {
59            Ok(line) => match parse_sentence(line) {
60                Ok(mut parsed) => {
61                    set_diagnostic_lines(&mut parsed.diagnostics, line_number);
62                    merge_diagnostics(&mut diagnostics, parsed.diagnostics);
63                    sentences.push(parsed.value);
64                }
65                Err(error) => push_error_skip_at_line(&mut diagnostics, error, line_number),
66            },
67            Err(_) => push_error_skip_at_line(
68                &mut diagnostics,
69                NmeaError::NotFramed {
70                    reason: "non-ASCII byte",
71                },
72                line_number,
73            ),
74        }
75    }
76    Parsed::new(NmeaLog { sentences }, diagnostics)
77}
78
79pub fn parse_nmea_str(text: &str) -> Parsed<NmeaLog> {
80    parse_nmea(text.as_bytes())
81}
82
83pub fn group_epochs(log: &NmeaLog) -> Vec<EpochSnapshot> {
84    let mut accumulator = NmeaAccumulator::new();
85    let mut snapshots = Vec::new();
86    for sentence in &log.sentences {
87        if let Some(snapshot) = accumulator.push(sentence) {
88            snapshots.push(snapshot);
89        }
90    }
91    if let Some(snapshot) = accumulator.finish() {
92        snapshots.push(snapshot);
93    }
94    snapshots
95}
96
97pub(crate) fn merge_diagnostics(target: &mut Diagnostics, mut source: Diagnostics) {
98    target.skips.append(&mut source.skips);
99    target.warnings.append(&mut source.warnings);
100}
101
102fn push_error_skip_at_line(diagnostics: &mut Diagnostics, error: NmeaError, line: usize) {
103    push_error_skip_at(diagnostics, error, RecordRef::at_line(line));
104}
105
106pub(crate) fn push_error_skip_at(diagnostics: &mut Diagnostics, error: NmeaError, at: RecordRef) {
107    let reason = match error {
108        NmeaError::NotFramed {
109            reason: "non-ASCII byte",
110        } => SkipReason::InconsistentRecord("non-ASCII byte"),
111        NmeaError::NotFramed {
112            reason: "sentence over length cap",
113        } => SkipReason::InconsistentRecord("sentence over length cap"),
114        NmeaError::NotFramed {
115            reason: "malformed checksum",
116        } => SkipReason::InconsistentRecord("malformed checksum"),
117        NmeaError::NotFramed { .. } => {
118            SkipReason::UnknownBlock("no NMEA start delimiter".to_string())
119        }
120        NmeaError::ChecksumMismatch { .. } => SkipReason::InconsistentRecord("checksum mismatch"),
121        NmeaError::UnsupportedType { ref address } if address == "encapsulated sentence" => {
122            SkipReason::UnsupportedRecordType("encapsulated sentence")
123        }
124        NmeaError::UnsupportedType { .. } => {
125            SkipReason::UnsupportedRecordType("unsupported sentence type")
126        }
127        NmeaError::Proprietary { .. } => SkipReason::UnsupportedRecordType("proprietary sentence"),
128        NmeaError::MalformedField(error) => SkipReason::MalformedField(error),
129        NmeaError::InvalidInput { .. } => SkipReason::InconsistentRecord("invalid input"),
130    };
131    diagnostics.push_skip(Skip { at, reason });
132}
133
134pub(crate) fn set_diagnostic_lines(diagnostics: &mut Diagnostics, line: usize) {
135    for skip in &mut diagnostics.skips {
136        if skip.at.line.is_none() {
137            skip.at.line = Some(line);
138        }
139    }
140    for warning in &mut diagnostics.warnings {
141        if warning.at.line.is_none() {
142            warning.at.line = Some(line);
143        }
144    }
145}