1use crate::frequencies::CarrierBand;
2use crate::validate::{self, FieldError};
3use crate::{GnssSatelliteId, GnssSystem, Wgs84Geodetic};
4use std::time::Duration;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct NmeaTime {
8 pub hour: u8,
9 pub minute: u8,
10 pub second: u8,
11 pub nanos: u32,
12 pub decimals: u8,
13}
14
15impl NmeaTime {
16 pub fn parse(token: &str) -> Result<Self, FieldError> {
17 let token = token.trim();
18 if token.is_empty() {
19 return Err(FieldError::Missing { field: "nmea time" });
20 }
21 let (whole, frac) = token.split_once('.').unwrap_or((token, ""));
22 if whole.len() != 6 || !whole.bytes().all(|b| b.is_ascii_digit()) {
23 return Err(FieldError::IntParse {
24 field: "nmea time",
25 value: token.to_string(),
26 });
27 }
28 if frac.len() > 9 || !frac.bytes().all(|b| b.is_ascii_digit()) {
29 return Err(FieldError::IntParse {
30 field: "nmea time fraction",
31 value: token.to_string(),
32 });
33 }
34 let hour = whole[0..2]
35 .parse::<u8>()
36 .map_err(|_| FieldError::IntParse {
37 field: "nmea time hour",
38 value: token.to_string(),
39 })?;
40 let minute = whole[2..4]
41 .parse::<u8>()
42 .map_err(|_| FieldError::IntParse {
43 field: "nmea time minute",
44 value: token.to_string(),
45 })?;
46 let second = whole[4..6]
47 .parse::<u8>()
48 .map_err(|_| FieldError::IntParse {
49 field: "nmea time second",
50 value: token.to_string(),
51 })?;
52 if hour > 23 || minute > 59 || second > 60 {
53 return Err(FieldError::InvalidCivilTime {
54 field: "nmea time",
55 hour: i64::from(hour),
56 minute: i64::from(minute),
57 second: f64::from(second),
58 });
59 }
60 let decimals = frac.len() as u8;
61 let frac_value = if frac.is_empty() {
62 0
63 } else {
64 frac.parse::<u32>().map_err(|_| FieldError::IntParse {
65 field: "nmea time fraction",
66 value: token.to_string(),
67 })?
68 };
69 let nanos = frac_value * 10_u32.pow(9 - u32::from(decimals));
70 Ok(Self {
71 hour,
72 minute,
73 second,
74 nanos,
75 decimals,
76 })
77 }
78
79 pub fn key(self) -> (u8, u8, u8, u32) {
80 (self.hour, self.minute, self.second, self.nanos)
81 }
82
83 pub fn from_seconds_of_day_floor_centis(seconds: f64) -> Result<Self, crate::nmea::NmeaError> {
84 if !seconds.is_finite() || !(0.0..86_400.0).contains(&seconds) {
85 return Err(crate::nmea::NmeaError::InvalidInput {
86 field: "time",
87 reason: "must be finite and in [0, 86400)",
88 });
89 }
90 let whole = seconds.floor() as u32;
91 let fractional = (seconds - f64::from(whole)).clamp(0.0, 1.0);
92 let centis = (Duration::from_secs_f64(fractional).as_nanos() / 10_000_000).min(99) as u32;
93 Ok(Self {
94 hour: (whole / 3600) as u8,
95 minute: ((whole % 3600) / 60) as u8,
96 second: (whole % 60) as u8,
97 nanos: centis * 10_000_000,
98 decimals: 2,
99 })
100 }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct NmeaCoordinate {
105 pub degrees: u16,
106 pub minutes_scaled: u64,
107 pub decimals: u8,
108 pub negative: bool,
109}
110
111impl NmeaCoordinate {
112 pub fn parse(value: &str, hemisphere: &str, is_latitude: bool) -> Result<Self, FieldError> {
113 let value = value.trim();
114 let hemisphere = hemisphere.trim();
115 if value.is_empty() || hemisphere.is_empty() {
116 return Err(FieldError::Missing {
117 field: if is_latitude { "latitude" } else { "longitude" },
118 });
119 }
120 let (negative, valid_hemisphere) = match hemisphere {
121 "N" => (false, is_latitude),
122 "S" => (true, is_latitude),
123 "E" => (false, !is_latitude),
124 "W" => (true, !is_latitude),
125 _ => (false, false),
126 };
127 if !valid_hemisphere {
128 return Err(FieldError::OutOfRange {
129 field: "hemisphere",
130 min: 0.0,
131 max: 0.0,
132 upper_inclusive: true,
133 });
134 }
135 let degree_digits = if is_latitude { 2 } else { 3 };
136 if value.len() < degree_digits + 2
137 || !value[..degree_digits + 2]
138 .bytes()
139 .all(|b| b.is_ascii_digit())
140 {
141 return Err(FieldError::FloatParse {
142 field: if is_latitude { "latitude" } else { "longitude" },
143 value: value.to_string(),
144 });
145 }
146 let degrees = value[..degree_digits]
147 .parse::<u16>()
148 .map_err(|_| FieldError::IntParse {
149 field: "coordinate degrees",
150 value: value.to_string(),
151 })?;
152 let minute_token = &value[degree_digits..];
153 let (whole_minutes, minute_frac) =
154 minute_token.split_once('.').unwrap_or((minute_token, ""));
155 if whole_minutes.len() != 2
156 || !whole_minutes.bytes().all(|b| b.is_ascii_digit())
157 || minute_frac.len() > 9
158 || !minute_frac.bytes().all(|b| b.is_ascii_digit())
159 {
160 return Err(FieldError::FloatParse {
161 field: "coordinate minutes",
162 value: value.to_string(),
163 });
164 }
165 let decimals = minute_frac.len() as u8;
166 let scale = 10_u64.pow(u32::from(decimals));
167 let minutes_whole = whole_minutes
168 .parse::<u64>()
169 .map_err(|_| FieldError::IntParse {
170 field: "coordinate minutes",
171 value: value.to_string(),
172 })?;
173 let frac_scaled = if minute_frac.is_empty() {
174 0
175 } else {
176 minute_frac
177 .parse::<u64>()
178 .map_err(|_| FieldError::IntParse {
179 field: "coordinate minute fraction",
180 value: value.to_string(),
181 })?
182 };
183 let minutes_scaled = minutes_whole * scale + frac_scaled;
184 let degree_max = if is_latitude { 90 } else { 180 };
185 if degrees > degree_max
186 || minutes_whole > 59
187 || (degrees == degree_max && minutes_scaled != 0)
188 {
189 return Err(FieldError::OutOfRange {
190 field: if is_latitude { "latitude" } else { "longitude" },
191 min: 0.0,
192 max: f64::from(degree_max),
193 upper_inclusive: true,
194 });
195 }
196 Ok(Self {
197 degrees,
198 minutes_scaled,
199 decimals,
200 negative,
201 })
202 }
203
204 pub fn from_degrees(
205 degrees: f64,
206 is_latitude: bool,
207 decimals: u8,
208 ) -> Result<Self, crate::nmea::NmeaError> {
209 if !degrees.is_finite() || decimals > 9 {
210 return Err(crate::nmea::NmeaError::InvalidInput {
211 field: "coordinate",
212 reason: "must be finite with at most 9 decimals",
213 });
214 }
215 let max = if is_latitude { 90.0 } else { 180.0 };
216 if degrees.abs() > max {
217 return Err(crate::nmea::NmeaError::InvalidInput {
218 field: "coordinate",
219 reason: "out of range",
220 });
221 }
222 let negative = degrees.is_sign_negative();
223 let abs = degrees.abs();
224 let mut whole_degrees = abs.floor() as u16;
225 let scale = 10_u64.pow(u32::from(decimals));
226 let minutes = (abs - f64::from(whole_degrees)) * 60.0;
227 let mut minutes_scaled = round_half_away_from_zero(minutes * scale as f64) as u64;
228 if minutes_scaled >= 60 * scale {
229 whole_degrees += 1;
230 minutes_scaled -= 60 * scale;
231 }
232 if f64::from(whole_degrees) > max {
233 return Err(crate::nmea::NmeaError::InvalidInput {
234 field: "coordinate",
235 reason: "rounding exceeded coordinate bound",
236 });
237 }
238 Ok(Self {
239 degrees: whole_degrees,
240 minutes_scaled,
241 decimals,
242 negative,
243 })
244 }
245
246 pub fn degrees_f64(&self) -> f64 {
247 let sign = if self.negative { -1.0 } else { 1.0 };
248 let scale = 10_f64.powi(i32::from(self.decimals));
249 sign * (f64::from(self.degrees) + (self.minutes_scaled as f64 / scale) / 60.0)
250 }
251
252 pub fn radians(&self) -> f64 {
253 self.degrees_f64().to_radians()
254 }
255}
256
257fn round_half_away_from_zero(value: f64) -> i64 {
258 if value >= 0.0 {
259 (value + 0.5).floor() as i64
260 } else {
261 (value - 0.5).ceil() as i64
262 }
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub struct NmeaDate {
267 pub year: u16,
268 pub month: u8,
269 pub day: u8,
270}
271
272impl NmeaDate {
273 pub fn parse_rmc(token: &str) -> Result<Self, FieldError> {
274 let token = token.trim();
275 if token.len() != 6 || !token.bytes().all(|b| b.is_ascii_digit()) {
276 return Err(FieldError::IntParse {
277 field: "nmea date",
278 value: token.to_string(),
279 });
280 }
281 let day = parse_u8(&token[0..2], "nmea date day")?;
282 let month = parse_u8(&token[2..4], "nmea date month")?;
283 let yy = parse_u8(&token[4..6], "nmea date year")?;
284 let year = if yy >= 80 {
285 1900 + u16::from(yy)
286 } else {
287 2000 + u16::from(yy)
288 };
289 Self::new(year, month, day)
290 }
291
292 pub fn new(year: u16, month: u8, day: u8) -> Result<Self, FieldError> {
293 let max_day = crate::astro::time::civil::days_in_month(i64::from(year), i64::from(month));
294 if max_day == 0 || day == 0 || i64::from(day) > max_day {
295 return Err(FieldError::InvalidCivilDate {
296 field: "nmea date",
297 year: i64::from(year),
298 month: i64::from(month),
299 day: i64::from(day),
300 });
301 }
302 Ok(Self { year, month, day })
303 }
304
305 pub fn next_day(self) -> Self {
306 let max_day =
307 crate::astro::time::civil::days_in_month(i64::from(self.year), i64::from(self.month))
308 as u8;
309 if self.day < max_day {
310 Self {
311 day: self.day + 1,
312 ..self
313 }
314 } else if self.month < 12 {
315 Self {
316 month: self.month + 1,
317 day: 1,
318 ..self
319 }
320 } else {
321 Self {
322 year: self.year + 1,
323 month: 1,
324 day: 1,
325 }
326 }
327 }
328}
329
330fn parse_u8(token: &str, field: &'static str) -> Result<u8, FieldError> {
331 token.parse::<u8>().map_err(|_| FieldError::IntParse {
332 field,
333 value: token.to_string(),
334 })
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
338pub enum NmeaTalker {
339 System(GnssSystem),
340 Combined,
341 Other([u8; 2]),
342}
343
344impl NmeaTalker {
345 pub fn parse(token: &str) -> Self {
346 match token.as_bytes() {
347 b"GP" => Self::System(GnssSystem::Gps),
348 b"GL" => Self::System(GnssSystem::Glonass),
349 b"GA" => Self::System(GnssSystem::Galileo),
350 b"GB" | b"BD" => Self::System(GnssSystem::BeiDou),
351 b"GQ" | b"QZ" => Self::System(GnssSystem::Qzss),
352 b"GI" => Self::System(GnssSystem::Navic),
353 b"GN" => Self::Combined,
354 [a, b] => Self::Other([*a, *b]),
355 _ => Self::Other([b'?', b'?']),
356 }
357 }
358
359 pub fn code(self) -> Result<[u8; 2], crate::nmea::NmeaError> {
360 match self {
361 Self::System(GnssSystem::Gps) | Self::System(GnssSystem::Sbas) => Ok(*b"GP"),
362 Self::System(GnssSystem::Glonass) => Ok(*b"GL"),
363 Self::System(GnssSystem::Galileo) => Ok(*b"GA"),
364 Self::System(GnssSystem::BeiDou) => Ok(*b"GB"),
365 Self::System(GnssSystem::Qzss) => Ok(*b"GQ"),
366 Self::System(GnssSystem::Navic) => Ok(*b"GI"),
367 Self::Combined => Ok(*b"GN"),
368 Self::Other(raw) if raw.iter().all(u8::is_ascii) => Ok(raw),
369 Self::Other(_) => Err(crate::nmea::NmeaError::InvalidInput {
370 field: "talker",
371 reason: "must be ASCII",
372 }),
373 }
374 }
375
376 pub fn system(self) -> Option<GnssSystem> {
377 match self {
378 Self::System(system) => Some(system),
379 Self::Combined | Self::Other(_) => None,
380 }
381 }
382}
383
384#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385pub enum GgaQuality {
386 Invalid,
387 GpsSps,
388 Differential,
389 Pps,
390 RtkFixed,
391 RtkFloat,
392 Estimated,
393 Manual,
394 Simulator,
395 Other(u8),
396}
397
398impl GgaQuality {
399 pub fn parse(token: &str) -> Result<Self, FieldError> {
400 let value = validate::strict_int::<u8>(token, "gga quality")?;
401 Ok(match value {
402 0 => Self::Invalid,
403 1 => Self::GpsSps,
404 2 => Self::Differential,
405 3 => Self::Pps,
406 4 => Self::RtkFixed,
407 5 => Self::RtkFloat,
408 6 => Self::Estimated,
409 7 => Self::Manual,
410 8 => Self::Simulator,
411 other => Self::Other(other),
412 })
413 }
414
415 pub fn value(self) -> u8 {
416 match self {
417 Self::Invalid => 0,
418 Self::GpsSps => 1,
419 Self::Differential => 2,
420 Self::Pps => 3,
421 Self::RtkFixed => 4,
422 Self::RtkFloat => 5,
423 Self::Estimated => 6,
424 Self::Manual => 7,
425 Self::Simulator => 8,
426 Self::Other(value) => value,
427 }
428 }
429}
430
431#[derive(Debug, Clone, PartialEq)]
432pub struct Gga {
433 pub time: Option<NmeaTime>,
434 pub latitude: Option<NmeaCoordinate>,
435 pub longitude: Option<NmeaCoordinate>,
436 pub quality: Option<GgaQuality>,
437 pub satellites_used: Option<u8>,
438 pub hdop: Option<f64>,
439 pub altitude_msl_m: Option<f64>,
440 pub geoid_separation_m: Option<f64>,
441 pub differential_age_s: Option<f64>,
442 pub differential_station_id: Option<u16>,
443}
444
445impl Gga {
446 pub fn vrs_position(
447 position: Wgs84Geodetic,
448 time: NmeaTime,
449 quality: GgaQuality,
450 satellites_used: u8,
451 hdop: f64,
452 coordinate_decimals: u8,
453 ) -> Result<Self, crate::nmea::NmeaError> {
454 if !hdop.is_finite() || hdop < 0.0 {
455 return Err(crate::nmea::NmeaError::InvalidInput {
456 field: "hdop",
457 reason: "must be finite and non-negative",
458 });
459 }
460 Ok(Self {
461 time: Some(time),
462 latitude: Some(NmeaCoordinate::from_degrees(
463 position.lat_rad.to_degrees(),
464 true,
465 coordinate_decimals,
466 )?),
467 longitude: Some(NmeaCoordinate::from_degrees(
468 position.lon_rad.to_degrees(),
469 false,
470 coordinate_decimals,
471 )?),
472 quality: Some(quality),
473 satellites_used: Some(satellites_used),
474 hdop: Some(hdop),
475 altitude_msl_m: Some(position.height_m),
476 geoid_separation_m: Some(0.0),
477 differential_age_s: None,
478 differential_station_id: None,
479 })
480 }
481}
482
483#[derive(Debug, Clone, Copy, PartialEq, Eq)]
484pub struct NmeaSatNumber {
485 pub raw: u16,
486 pub resolved: Option<GnssSatelliteId>,
487}
488
489#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490pub struct NmeaSignalId {
491 pub system: Option<GnssSystem>,
492 pub id: u8,
493}
494
495impl NmeaSignalId {
496 pub fn carrier_band(&self) -> Option<CarrierBand> {
497 let system = self.system?;
498 match system {
499 GnssSystem::Gps | GnssSystem::Sbas => match self.id {
500 1..=3 => Some(CarrierBand::L1),
501 4..=6 => Some(CarrierBand::L2),
502 7 | 8 => Some(CarrierBand::L5),
503 _ => None,
504 },
505 GnssSystem::Glonass => match self.id {
506 1 | 2 => Some(CarrierBand::G1),
507 3 | 4 => Some(CarrierBand::G2),
508 _ => None,
509 },
510 GnssSystem::Galileo => match self.id {
511 1 => Some(CarrierBand::E5a),
512 2 => Some(CarrierBand::E5b),
513 3 => Some(CarrierBand::E5),
514 4 | 5 => Some(CarrierBand::E6),
515 6 | 7 => Some(CarrierBand::E1),
516 _ => None,
517 },
518 GnssSystem::BeiDou => match self.id {
519 1 | 2 => Some(CarrierBand::B1i),
520 3 | 4 => Some(CarrierBand::B1c),
521 5 => Some(CarrierBand::B2a),
522 6 => Some(CarrierBand::B2b),
523 7 => Some(CarrierBand::B2),
524 8 | 9 => Some(CarrierBand::B3i),
525 _ => None,
526 },
527 GnssSystem::Qzss => match self.id {
528 1..=4 => Some(CarrierBand::L1),
529 5 | 6 => Some(CarrierBand::L2),
530 7 | 8 => Some(CarrierBand::L5),
531 _ => None,
532 },
533 GnssSystem::Navic => match self.id {
534 1 | 3 => Some(CarrierBand::L5),
535 _ => None,
536 },
537 }
538 }
539}
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq)]
542pub enum RmcStatus {
543 Valid,
544 Warning,
545 Other(char),
546}
547
548#[derive(Debug, Clone, Copy, PartialEq, Eq)]
549pub enum GsaSelectionMode {
550 Manual,
551 Automatic,
552 Other(char),
553}
554
555#[derive(Debug, Clone, Copy, PartialEq, Eq)]
556pub enum GsaFixMode {
557 None,
558 TwoD,
559 ThreeD,
560 Other(u8),
561}
562
563#[derive(Debug, Clone, PartialEq)]
564pub struct Rmc {
565 pub time: Option<NmeaTime>,
566 pub status: Option<RmcStatus>,
567 pub latitude: Option<NmeaCoordinate>,
568 pub longitude: Option<NmeaCoordinate>,
569 pub speed_over_ground_kn: Option<f64>,
570 pub course_over_ground_deg: Option<f64>,
571 pub date: Option<NmeaDate>,
572 pub magnetic_variation_deg: Option<f64>,
573 pub faa_mode: Option<char>,
574 pub navigational_status: Option<char>,
575}
576
577#[derive(Debug, Clone, PartialEq)]
578pub struct Gsa {
579 pub selection_mode: Option<GsaSelectionMode>,
580 pub fix_mode: Option<GsaFixMode>,
581 pub satellites: Vec<NmeaSatNumber>,
582 pub pdop: Option<f64>,
583 pub hdop: Option<f64>,
584 pub vdop: Option<f64>,
585 pub system_id: Option<u8>,
586 pub system: Option<GnssSystem>,
587}
588
589#[derive(Debug, Clone, PartialEq)]
590pub struct GsvSatellite {
591 pub sat_number: Option<NmeaSatNumber>,
592 pub elevation_deg: Option<i16>,
593 pub azimuth_deg: Option<u16>,
594 pub cn0_db_hz: Option<u8>,
595}
596
597#[derive(Debug, Clone, PartialEq)]
598pub struct Gsv {
599 pub total_messages: u8,
600 pub message_number: u8,
601 pub satellites_in_view: Option<u16>,
602 pub satellites: Vec<GsvSatellite>,
603 pub signal: Option<NmeaSignalId>,
604}
605
606#[derive(Debug, Clone, PartialEq)]
607pub struct Gst {
608 pub time: Option<NmeaTime>,
609 pub rms_range_residual_m: Option<f64>,
610 pub semi_major_error_m: Option<f64>,
611 pub semi_minor_error_m: Option<f64>,
612 pub orientation_deg: Option<f64>,
613 pub latitude_sigma_m: Option<f64>,
614 pub longitude_sigma_m: Option<f64>,
615 pub altitude_sigma_m: Option<f64>,
616}
617
618#[derive(Debug, Clone, PartialEq)]
619pub struct Vtg {
620 pub course_true_deg: Option<f64>,
621 pub course_magnetic_deg: Option<f64>,
622 pub speed_kn: Option<f64>,
623 pub speed_kmh: Option<f64>,
624 pub faa_mode: Option<char>,
625}
626
627#[derive(Debug, Clone, PartialEq)]
628pub struct Gll {
629 pub latitude: Option<NmeaCoordinate>,
630 pub longitude: Option<NmeaCoordinate>,
631 pub time: Option<NmeaTime>,
632 pub status: Option<RmcStatus>,
633 pub faa_mode: Option<char>,
634}
635
636#[derive(Debug, Clone, PartialEq)]
637pub struct Zda {
638 pub time: Option<NmeaTime>,
639 pub date: Option<NmeaDate>,
640 pub local_zone_hours: Option<i8>,
641 pub local_zone_minutes: Option<u8>,
642}
643
644pub(crate) fn resolve_sat_number(context: Option<GnssSystem>, raw: u16) -> Option<GnssSatelliteId> {
645 let candidate = match context {
646 Some(GnssSystem::Gps) => match raw {
647 1..=32 => Some((GnssSystem::Gps, raw)),
648 33..=64 => Some((GnssSystem::Sbas, raw - 13)),
649 _ => None,
650 },
651 Some(GnssSystem::Glonass) => match raw {
652 65..=99 => Some((GnssSystem::Glonass, raw - 64)),
653 1..=35 => Some((GnssSystem::Glonass, raw)),
654 _ => None,
655 },
656 Some(GnssSystem::Galileo) => match raw {
657 1..=36 => Some((GnssSystem::Galileo, raw)),
658 _ => None,
659 },
660 Some(GnssSystem::BeiDou) => match raw {
661 1..=64 => Some((GnssSystem::BeiDou, raw)),
662 _ => None,
663 },
664 Some(GnssSystem::Qzss) => match raw {
665 1..=10 => Some((GnssSystem::Qzss, raw)),
666 193..=202 => Some((GnssSystem::Qzss, raw - 192)),
667 _ => None,
668 },
669 Some(GnssSystem::Navic) => match raw {
670 1..=15 => Some((GnssSystem::Navic, raw)),
671 _ => None,
672 },
673 Some(GnssSystem::Sbas) => match raw {
674 33..=64 => Some((GnssSystem::Sbas, raw - 13)),
675 120..=158 => Some((GnssSystem::Sbas, raw - 100)),
676 _ => None,
677 },
678 None => match raw {
679 1..=32 => Some((GnssSystem::Gps, raw)),
680 33..=64 => Some((GnssSystem::Sbas, raw - 13)),
681 65..=99 => Some((GnssSystem::Glonass, raw - 64)),
682 193..=202 => Some((GnssSystem::Qzss, raw - 192)),
683 _ => None,
684 },
685 }?;
686 let prn = u8::try_from(candidate.1).ok()?;
687 GnssSatelliteId::new(candidate.0, prn).ok()
688}