1use crate::format::{Diagnostics, Parsed, RecordRef, Warning, WarningKind};
2use crate::validate::{self, FieldError};
3use crate::GnssSystem;
4
5use super::{
6 Gga, GgaQuality, Gll, Gsa, GsaFixMode, GsaSelectionMode, Gst, Gsv, GsvSatellite,
7 NmeaCoordinate, NmeaDate, NmeaError, NmeaSatNumber, NmeaSignalId, NmeaTalker, NmeaTime, Rmc,
8 RmcStatus, Vtg, Zda,
9};
10
11#[derive(Debug, Clone, PartialEq)]
12pub struct NmeaSentence {
13 pub talker: NmeaTalker,
14 pub body: NmeaBody,
15}
16
17#[derive(Debug, Clone, PartialEq)]
18pub enum NmeaBody {
19 Gga(Gga),
20 Rmc(Rmc),
21 Gsa(Gsa),
22 Gsv(Gsv),
23 Gst(Gst),
24 Vtg(Vtg),
25 Gll(Gll),
26 Zda(Zda),
27}
28
29pub(crate) struct FramedSentence<'a> {
30 pub delimiter: u8,
31 pub body: &'a str,
32 pub diagnostics: Diagnostics,
33}
34
35pub(crate) fn checksum_body(body: &str) -> u8 {
36 body.bytes().fold(0, |acc, byte| acc ^ byte)
37}
38
39pub(crate) fn frame_sentence(line: &str) -> Result<FramedSentence<'_>, NmeaError> {
40 let mut diagnostics = Diagnostics::new();
41 let trimmed = line.trim_end_matches(['\r', '\n']);
42 let start =
43 trimmed
44 .bytes()
45 .position(|b| b == b'$' || b == b'!')
46 .ok_or(NmeaError::NotFramed {
47 reason: "no NMEA start delimiter",
48 })?;
49 if start > 0 {
50 diagnostics.push_warning(Warning {
51 at: RecordRef::default(),
52 kind: WarningKind::Mismatch,
53 });
54 }
55 let sentence = &trimmed[start..];
56 if sentence.len() > 1024 {
57 return Err(NmeaError::NotFramed {
58 reason: "sentence over length cap",
59 });
60 }
61 if !sentence.is_ascii() {
62 return Err(NmeaError::NotFramed {
63 reason: "non-ASCII byte",
64 });
65 }
66 let delimiter = sentence.as_bytes()[0];
67 let rest = &sentence[1..];
68 let (body, checksum) = if let Some(star) = rest.rfind('*') {
69 let checksum_token = rest.get(star + 1..star + 3).unwrap_or("");
70 if checksum_token.len() != 2 || !checksum_token.bytes().all(|b| b.is_ascii_hexdigit()) {
71 return Err(NmeaError::NotFramed {
72 reason: "malformed checksum",
73 });
74 }
75 let trailing = &rest[star + 3..];
76 if !trailing
77 .bytes()
78 .all(|b| b == b' ' || b == b'\r' || b == b'\n')
79 {
80 diagnostics.push_warning(Warning {
81 at: RecordRef::default(),
82 kind: WarningKind::Mismatch,
83 });
84 }
85 let stated = u8::from_str_radix(checksum_token, 16).map_err(|_| NmeaError::NotFramed {
86 reason: "malformed checksum",
87 })?;
88 (&rest[..star], Some(stated))
89 } else {
90 diagnostics.push_warning(Warning {
91 at: RecordRef::default(),
92 kind: WarningKind::MissingMetadata,
93 });
94 (rest, None)
95 };
96 if let Some(stated) = checksum {
97 let computed = checksum_body(body);
98 if computed != stated {
99 return Err(NmeaError::ChecksumMismatch { computed, stated });
100 }
101 }
102 Ok(FramedSentence {
103 delimiter,
104 body,
105 diagnostics,
106 })
107}
108
109pub(crate) fn parse_framed(framed: FramedSentence<'_>) -> Result<Parsed<NmeaSentence>, NmeaError> {
110 if framed.delimiter == b'!' {
111 return Err(NmeaError::UnsupportedType {
112 address: "encapsulated sentence".to_string(),
113 });
114 }
115 let mut diagnostics = framed.diagnostics;
116 let mut parts = framed.body.split(',');
117 let address = parts.next().unwrap_or_default();
118 if address.starts_with('P') {
119 return Err(NmeaError::Proprietary {
120 address: address.to_string(),
121 });
122 }
123 if address.len() != 5 {
124 return Err(NmeaError::UnsupportedType {
125 address: address.to_string(),
126 });
127 }
128 let talker = NmeaTalker::parse(&address[..2]);
129 let sentence_type = &address[2..];
130 let fields: Vec<&str> = parts.collect();
131 let body = match sentence_type {
132 "GGA" => NmeaBody::Gga(parse_gga(&fields)?),
133 "RMC" => NmeaBody::Rmc(parse_rmc(&fields)?),
134 "GSA" => NmeaBody::Gsa(parse_gsa(talker, &fields, &mut diagnostics)?),
135 "GSV" => NmeaBody::Gsv(parse_gsv(talker, &fields, &mut diagnostics)?),
136 "GST" => NmeaBody::Gst(parse_gst(&fields)?),
137 "VTG" => NmeaBody::Vtg(parse_vtg(&fields)?),
138 "GLL" => NmeaBody::Gll(parse_gll(&fields)?),
139 "ZDA" => NmeaBody::Zda(parse_zda(&fields)?),
140 _ => {
141 return Err(NmeaError::UnsupportedType {
142 address: address.to_string(),
143 })
144 }
145 };
146 Ok(Parsed::new(NmeaSentence { talker, body }, diagnostics))
147}
148
149fn parse_gga(fields: &[&str]) -> Result<Gga, FieldError> {
150 Ok(Gga {
151 time: parse_opt_time(fields.first().copied())?,
152 latitude: parse_opt_coordinate(fields.get(1).copied(), fields.get(2).copied(), true)?,
153 longitude: parse_opt_coordinate(fields.get(3).copied(), fields.get(4).copied(), false)?,
154 quality: parse_opt_quality(fields.get(5).copied())?,
155 satellites_used: parse_opt_u8_range(fields.get(6).copied(), "satellites used", 0, 99)?,
156 hdop: parse_opt_f64(fields.get(7).copied(), "hdop")?,
157 altitude_msl_m: parse_opt_unit_f64(
158 fields.get(8).copied(),
159 fields.get(9).copied(),
160 "altitude msl",
161 )?,
162 geoid_separation_m: parse_opt_unit_f64(
163 fields.get(10).copied(),
164 fields.get(11).copied(),
165 "geoid separation",
166 )?,
167 differential_age_s: parse_opt_f64(fields.get(12).copied(), "differential age")?,
168 differential_station_id: parse_opt_u16_range(
169 fields.get(13).copied(),
170 "differential station id",
171 0,
172 9999,
173 )?,
174 })
175}
176
177fn parse_rmc(fields: &[&str]) -> Result<Rmc, FieldError> {
178 let magnetic = parse_opt_signed_pair(
179 fields.get(9).copied(),
180 fields.get(10).copied(),
181 "magnetic variation",
182 "EW",
183 )?;
184 Ok(Rmc {
185 time: parse_opt_time(fields.first().copied())?,
186 status: parse_opt_rmc_status(fields.get(1).copied())?,
187 latitude: parse_opt_coordinate(fields.get(2).copied(), fields.get(3).copied(), true)?,
188 longitude: parse_opt_coordinate(fields.get(4).copied(), fields.get(5).copied(), false)?,
189 speed_over_ground_kn: parse_opt_f64(fields.get(6).copied(), "speed over ground")?,
190 course_over_ground_deg: parse_opt_f64(fields.get(7).copied(), "course over ground")?,
191 date: parse_opt_date_rmc(fields.get(8).copied())?,
192 magnetic_variation_deg: magnetic,
193 faa_mode: parse_opt_char(fields.get(11).copied(), "faa mode")?,
194 navigational_status: parse_opt_char(fields.get(12).copied(), "navigation status")?,
195 })
196}
197
198fn parse_gsa(
199 talker: NmeaTalker,
200 fields: &[&str],
201 diagnostics: &mut Diagnostics,
202) -> Result<Gsa, FieldError> {
203 let system_id = parse_opt_u8_range(fields.get(17).copied(), "system id", 0, 9)?;
204 let system = match system_id {
205 Some(1) => Some(GnssSystem::Gps),
206 Some(2) => Some(GnssSystem::Glonass),
207 Some(3) => Some(GnssSystem::Galileo),
208 Some(4) => Some(GnssSystem::BeiDou),
209 Some(5) => Some(GnssSystem::Qzss),
210 Some(6) => Some(GnssSystem::Navic),
211 Some(_) => {
212 diagnostics.push_warning(Warning {
213 at: RecordRef::default(),
214 kind: WarningKind::Degraded,
215 });
216 None
217 }
218 None => talker.system(),
219 };
220 let mut satellites = Vec::new();
221 for field in fields.iter().skip(2).take(12) {
222 if let Some(sat) = parse_opt_sat_number(Some(*field), system, diagnostics)? {
223 satellites.push(sat);
224 }
225 }
226 Ok(Gsa {
227 selection_mode: parse_opt_gsa_selection(fields.first().copied())?,
228 fix_mode: parse_opt_gsa_fix(fields.get(1).copied())?,
229 satellites,
230 pdop: parse_opt_f64(fields.get(14).copied(), "pdop")?,
231 hdop: parse_opt_f64(fields.get(15).copied(), "hdop")?,
232 vdop: parse_opt_f64(fields.get(16).copied(), "vdop")?,
233 system_id,
234 system,
235 })
236}
237
238fn parse_gsv(
239 talker: NmeaTalker,
240 fields: &[&str],
241 diagnostics: &mut Diagnostics,
242) -> Result<Gsv, FieldError> {
243 let total_messages = parse_required_u8_range(fields.first().copied(), "gsv total", 1, 15)?;
244 let message_number = parse_required_u8_range(
245 fields.get(1).copied(),
246 "gsv message number",
247 1,
248 total_messages,
249 )?;
250 let satellites_in_view =
251 parse_opt_u16_range(fields.get(2).copied(), "satellites in view", 0, 999)?;
252 let tail = if fields.len() > 3 { &fields[3..] } else { &[] };
253 let rem = tail.len() % 4;
254 let (sat_fields, signal) = if rem == 1 {
255 let signal = parse_opt_signal_id(tail.last().copied(), talker.system(), diagnostics)?;
256 (&tail[..tail.len() - 1], signal)
257 } else {
258 if rem == 2 || rem == 3 {
259 diagnostics.push_warning(Warning {
260 at: RecordRef::default(),
261 kind: WarningKind::Mismatch,
262 });
263 }
264 (tail, None)
265 };
266 let mut satellites = Vec::new();
267 for chunk in sat_fields.chunks(4) {
268 let sat_number =
269 parse_opt_sat_number(chunk.first().copied(), talker.system(), diagnostics)?;
270 let elevation_deg = parse_opt_i16_range(chunk.get(1).copied(), "elevation", -90, 90)?;
271 let azimuth_deg = parse_opt_u16_range(chunk.get(2).copied(), "azimuth", 0, 359)?;
272 let cn0_db_hz = parse_opt_u8_range(chunk.get(3).copied(), "cn0", 0, 99)?;
273 satellites.push(GsvSatellite {
274 sat_number,
275 elevation_deg,
276 azimuth_deg,
277 cn0_db_hz,
278 });
279 }
280 Ok(Gsv {
281 total_messages,
282 message_number,
283 satellites_in_view,
284 satellites,
285 signal,
286 })
287}
288
289fn parse_gst(fields: &[&str]) -> Result<Gst, FieldError> {
290 Ok(Gst {
291 time: parse_opt_time(fields.first().copied())?,
292 rms_range_residual_m: parse_opt_f64(fields.get(1).copied(), "range residual rms")?,
293 semi_major_error_m: parse_opt_f64(fields.get(2).copied(), "semi major error")?,
294 semi_minor_error_m: parse_opt_f64(fields.get(3).copied(), "semi minor error")?,
295 orientation_deg: parse_opt_f64(fields.get(4).copied(), "error orientation")?,
296 latitude_sigma_m: parse_opt_f64(fields.get(5).copied(), "latitude sigma")?,
297 longitude_sigma_m: parse_opt_f64(fields.get(6).copied(), "longitude sigma")?,
298 altitude_sigma_m: parse_opt_f64(fields.get(7).copied(), "altitude sigma")?,
299 })
300}
301
302fn parse_vtg(fields: &[&str]) -> Result<Vtg, FieldError> {
303 Ok(Vtg {
304 course_true_deg: parse_opt_tagged_f64(
305 fields.first().copied(),
306 fields.get(1).copied(),
307 "course true",
308 "T",
309 )?,
310 course_magnetic_deg: parse_opt_tagged_f64(
311 fields.get(2).copied(),
312 fields.get(3).copied(),
313 "course magnetic",
314 "M",
315 )?,
316 speed_kn: parse_opt_tagged_f64(
317 fields.get(4).copied(),
318 fields.get(5).copied(),
319 "speed knots",
320 "N",
321 )?,
322 speed_kmh: parse_opt_tagged_f64(
323 fields.get(6).copied(),
324 fields.get(7).copied(),
325 "speed kmh",
326 "K",
327 )?,
328 faa_mode: parse_opt_char(fields.get(8).copied(), "faa mode")?,
329 })
330}
331
332fn parse_gll(fields: &[&str]) -> Result<Gll, FieldError> {
333 Ok(Gll {
334 latitude: parse_opt_coordinate(fields.first().copied(), fields.get(1).copied(), true)?,
335 longitude: parse_opt_coordinate(fields.get(2).copied(), fields.get(3).copied(), false)?,
336 time: parse_opt_time(fields.get(4).copied())?,
337 status: parse_opt_rmc_status(fields.get(5).copied())?,
338 faa_mode: parse_opt_char(fields.get(6).copied(), "faa mode")?,
339 })
340}
341
342fn parse_zda(fields: &[&str]) -> Result<Zda, FieldError> {
343 let day = parse_opt_u8_range(fields.get(1).copied(), "zda day", 1, 31)?;
344 let month = parse_opt_u8_range(fields.get(2).copied(), "zda month", 1, 12)?;
345 let year = parse_opt_u16_range(fields.get(3).copied(), "zda year", 0, 9999)?;
346 let date = match (year, month, day) {
347 (Some(year), Some(month), Some(day)) => Some(NmeaDate::new(year, month, day)?),
348 (None, None, None) => None,
349 _ => return Err(FieldError::Missing { field: "zda date" }),
350 };
351 Ok(Zda {
352 time: parse_opt_time(fields.first().copied())?,
353 date,
354 local_zone_hours: parse_opt_i8_range(fields.get(4).copied(), "zone hours", -13, 13)?,
355 local_zone_minutes: parse_opt_u8_range(fields.get(5).copied(), "zone minutes", 0, 59)?,
356 })
357}
358
359fn parse_opt_time(token: Option<&str>) -> Result<Option<NmeaTime>, FieldError> {
360 match token.unwrap_or("").trim() {
361 "" => Ok(None),
362 token => NmeaTime::parse(token).map(Some),
363 }
364}
365
366fn parse_opt_date_rmc(token: Option<&str>) -> Result<Option<NmeaDate>, FieldError> {
367 match token.unwrap_or("").trim() {
368 "" => Ok(None),
369 token => NmeaDate::parse_rmc(token).map(Some),
370 }
371}
372
373fn parse_opt_coordinate(
374 value: Option<&str>,
375 hemisphere: Option<&str>,
376 is_latitude: bool,
377) -> Result<Option<NmeaCoordinate>, FieldError> {
378 let value = value.unwrap_or("").trim();
379 let hemisphere = hemisphere.unwrap_or("").trim();
380 match (value.is_empty(), hemisphere.is_empty()) {
381 (true, true) => Ok(None),
382 (false, false) => NmeaCoordinate::parse(value, hemisphere, is_latitude).map(Some),
383 _ => Err(FieldError::Missing {
384 field: if is_latitude {
385 "latitude pair"
386 } else {
387 "longitude pair"
388 },
389 }),
390 }
391}
392
393fn parse_opt_quality(token: Option<&str>) -> Result<Option<GgaQuality>, FieldError> {
394 match token.unwrap_or("").trim() {
395 "" => Ok(None),
396 token => GgaQuality::parse(token).map(Some),
397 }
398}
399
400fn parse_opt_rmc_status(token: Option<&str>) -> Result<Option<RmcStatus>, FieldError> {
401 match parse_opt_char(token, "rmc status")? {
402 None => Ok(None),
403 Some('A') => Ok(Some(RmcStatus::Valid)),
404 Some('V') => Ok(Some(RmcStatus::Warning)),
405 Some(ch) => Ok(Some(RmcStatus::Other(ch))),
406 }
407}
408
409fn parse_opt_gsa_selection(token: Option<&str>) -> Result<Option<GsaSelectionMode>, FieldError> {
410 match parse_opt_char(token, "gsa selection")? {
411 None => Ok(None),
412 Some('M') => Ok(Some(GsaSelectionMode::Manual)),
413 Some('A') => Ok(Some(GsaSelectionMode::Automatic)),
414 Some(ch) => Ok(Some(GsaSelectionMode::Other(ch))),
415 }
416}
417
418fn parse_opt_gsa_fix(token: Option<&str>) -> Result<Option<GsaFixMode>, FieldError> {
419 match token.unwrap_or("").trim() {
420 "" => Ok(None),
421 token => {
422 let value = validate::strict_int::<u8>(token, "gsa fix mode")?;
423 Ok(Some(match value {
424 1 => GsaFixMode::None,
425 2 => GsaFixMode::TwoD,
426 3 => GsaFixMode::ThreeD,
427 other => GsaFixMode::Other(other),
428 }))
429 }
430 }
431}
432
433fn parse_opt_char(token: Option<&str>, field: &'static str) -> Result<Option<char>, FieldError> {
434 let token = token.unwrap_or("").trim();
435 if token.is_empty() {
436 return Ok(None);
437 }
438 let mut chars = token.chars();
439 let Some(ch) = chars.next() else {
440 return Ok(None);
441 };
442 if chars.next().is_some() {
443 return Err(FieldError::IntParse {
444 field,
445 value: token.to_string(),
446 });
447 }
448 Ok(Some(ch))
449}
450
451fn parse_opt_f64(token: Option<&str>, field: &'static str) -> Result<Option<f64>, FieldError> {
452 match token.unwrap_or("").trim() {
453 "" => Ok(None),
454 token => validate::strict_f64(token, field).map(Some),
455 }
456}
457
458fn parse_opt_tagged_f64(
459 value: Option<&str>,
460 unit: Option<&str>,
461 field: &'static str,
462 expected_unit: &str,
463) -> Result<Option<f64>, FieldError> {
464 let value = value.unwrap_or("").trim();
465 let unit = unit.unwrap_or("").trim();
466 if value.is_empty() {
467 return Ok(None);
468 }
469 if unit != expected_unit {
470 return Err(FieldError::OutOfRange {
471 field: "unit",
472 min: 0.0,
473 max: 0.0,
474 upper_inclusive: true,
475 });
476 }
477 validate::strict_f64(value, field).map(Some)
478}
479
480fn parse_opt_signed_pair(
481 value: Option<&str>,
482 direction: Option<&str>,
483 field: &'static str,
484 valid_dirs: &str,
485) -> Result<Option<f64>, FieldError> {
486 let Some(mut value) = parse_opt_f64(value, field)? else {
487 return Ok(None);
488 };
489 let direction = direction.unwrap_or("").trim();
490 if direction.len() != 1 || !valid_dirs.contains(direction) {
491 return Err(FieldError::OutOfRange {
492 field: "direction",
493 min: 0.0,
494 max: 0.0,
495 upper_inclusive: true,
496 });
497 }
498 if direction == "W" || direction == "S" {
499 value = -value;
500 }
501 Ok(Some(value))
502}
503
504fn parse_opt_unit_f64(
505 value: Option<&str>,
506 unit: Option<&str>,
507 field: &'static str,
508) -> Result<Option<f64>, FieldError> {
509 let value = value.unwrap_or("").trim();
510 let unit = unit.unwrap_or("").trim();
511 if value.is_empty() {
512 if unit.is_empty() {
513 return Ok(None);
514 }
515 return Err(FieldError::Missing { field });
516 }
517 if unit != "M" {
518 return Err(FieldError::OutOfRange {
519 field: "unit",
520 min: 0.0,
521 max: 0.0,
522 upper_inclusive: true,
523 });
524 }
525 validate::strict_f64(value, field).map(Some)
526}
527
528fn parse_required_u8_range(
529 token: Option<&str>,
530 field: &'static str,
531 min: u8,
532 max: u8,
533) -> Result<u8, FieldError> {
534 parse_opt_u8_range(token, field, min, max)?.ok_or(FieldError::Missing { field })
535}
536
537fn parse_opt_u8_range(
538 token: Option<&str>,
539 field: &'static str,
540 min: u8,
541 max: u8,
542) -> Result<Option<u8>, FieldError> {
543 match token.unwrap_or("").trim() {
544 "" => Ok(None),
545 token => {
546 let value = validate::strict_int::<u8>(token, field)?;
547 if value < min || value > max {
548 Err(FieldError::OutOfRange {
549 field,
550 min: f64::from(min),
551 max: f64::from(max),
552 upper_inclusive: true,
553 })
554 } else {
555 Ok(Some(value))
556 }
557 }
558 }
559}
560
561fn parse_opt_i8_range(
562 token: Option<&str>,
563 field: &'static str,
564 min: i8,
565 max: i8,
566) -> Result<Option<i8>, FieldError> {
567 match token.unwrap_or("").trim() {
568 "" => Ok(None),
569 token => {
570 let value = validate::strict_int::<i8>(token, field)?;
571 if value < min || value > max {
572 Err(FieldError::OutOfRange {
573 field,
574 min: f64::from(min),
575 max: f64::from(max),
576 upper_inclusive: true,
577 })
578 } else {
579 Ok(Some(value))
580 }
581 }
582 }
583}
584
585fn parse_opt_i16_range(
586 token: Option<&str>,
587 field: &'static str,
588 min: i16,
589 max: i16,
590) -> Result<Option<i16>, FieldError> {
591 match token.unwrap_or("").trim() {
592 "" => Ok(None),
593 token => {
594 let value = validate::strict_int::<i16>(token, field)?;
595 if value < min || value > max {
596 Err(FieldError::OutOfRange {
597 field,
598 min: f64::from(min),
599 max: f64::from(max),
600 upper_inclusive: true,
601 })
602 } else {
603 Ok(Some(value))
604 }
605 }
606 }
607}
608
609fn parse_opt_u16_range(
610 token: Option<&str>,
611 field: &'static str,
612 min: u16,
613 max: u16,
614) -> Result<Option<u16>, FieldError> {
615 match token.unwrap_or("").trim() {
616 "" => Ok(None),
617 token => {
618 let value = validate::strict_int::<u16>(token, field)?;
619 if value < min || value > max {
620 Err(FieldError::OutOfRange {
621 field,
622 min: f64::from(min),
623 max: f64::from(max),
624 upper_inclusive: true,
625 })
626 } else {
627 Ok(Some(value))
628 }
629 }
630 }
631}
632
633fn parse_opt_sat_number(
634 token: Option<&str>,
635 context: Option<GnssSystem>,
636 diagnostics: &mut Diagnostics,
637) -> Result<Option<NmeaSatNumber>, FieldError> {
638 let token = token.unwrap_or("").trim();
639 if token.is_empty() {
640 return Ok(None);
641 }
642 let raw = validate::strict_int::<u16>(token, "satellite number")?;
643 let resolved = super::fields::resolve_sat_number(context, raw);
644 if resolved.is_none() {
645 diagnostics.push_warning(Warning {
646 at: RecordRef::default(),
647 kind: WarningKind::Degraded,
648 });
649 }
650 Ok(Some(NmeaSatNumber { raw, resolved }))
651}
652
653fn parse_opt_signal_id(
654 token: Option<&str>,
655 system: Option<GnssSystem>,
656 diagnostics: &mut Diagnostics,
657) -> Result<Option<NmeaSignalId>, FieldError> {
658 let token = token.unwrap_or("").trim();
659 if token.is_empty() {
660 return Ok(None);
661 }
662 let id = if token.len() == 1 {
663 u8::from_str_radix(token, 16).map_err(|_| FieldError::IntParse {
664 field: "signal id",
665 value: token.to_string(),
666 })?
667 } else {
668 validate::strict_int::<u8>(token, "signal id")?
669 };
670 let signal = NmeaSignalId { system, id };
671 if id > 15 || signal.carrier_band().is_none() {
672 diagnostics.push_warning(Warning {
673 at: RecordRef::default(),
674 kind: WarningKind::Degraded,
675 });
676 }
677 Ok(Some(signal))
678}