umsh_gnss/
driver.rs

1//! Coalescing a receiver's sentence stream into one fix per cycle.
2//!
3//! A receiver says what it knows across four or more sentences a second,
4//! each carrying a different part of the answer: position and date in
5//! RMC, altitude and satellite count in GGA, the solution's dimension and
6//! dilution in GSA, visibility in GSV. Nothing upstream wants four
7//! partial answers a second, so [`Driver`] accumulates them and hands
8//! over one [`Fix`] per cycle.
9//!
10//! **RMC ends a cycle.** It is the only sentence carrying a date, so it
11//! is the one that can establish what time it is, and cutting the cycle
12//! there means every emitted fix either has a usable instant or is
13//! honestly missing one. A receiver that emits RMC first rather than last
14//! simply attaches the previous second's altitude to it, which is a
15//! second of staleness in a field that changes slowly.
16
17use crate::DateTime;
18use crate::nmea::{Assembler, Gsv, Sentence};
19
20/// How many constellations' satellite counts are summed.
21///
22/// A multi-constellation receiver emits one GSV burst per constellation.
23/// Four covers GPS, GLONASS, Galileo and BeiDou together; a fifth is
24/// ignored rather than displacing one, so the count is a floor.
25const MAX_CONSTELLATIONS: usize = 4;
26
27/// The quality of a position solution.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub enum FixQuality {
30    /// No position.
31    #[default]
32    None,
33    /// Position without altitude.
34    TwoD,
35    /// Position with altitude.
36    ThreeD,
37}
38
39/// Everything one cycle of sentences said, in integers.
40///
41/// Coordinates are in units of 1e-7 degrees — about 11 mm, and exact,
42/// because they were parsed from the receiver's decimal digits without
43/// ever passing through a float.
44#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
45pub struct Fix {
46    /// The dimension of the solution.
47    pub quality: FixQuality,
48    /// Latitude in 1e-7 degrees, positive north. `None` without a fix.
49    pub latitude_e7: Option<i32>,
50    /// Longitude in 1e-7 degrees, positive east. `None` without a fix.
51    pub longitude_e7: Option<i32>,
52    /// Altitude above mean sea level in meters, with a three-dimensional
53    /// solution.
54    pub altitude_m: Option<i32>,
55    /// Horizontal dilution of precision, in hundredths.
56    pub hdop_centi: Option<u16>,
57    /// Satellites contributing to the solution.
58    pub sats_used: u8,
59    /// Satellites in view, summed across constellations. `None` when the
60    /// receiver reported no GSV since the last cycle.
61    pub sats_in_view: Option<u8>,
62    /// The UTC instant the receiver reported, if it reported one.
63    ///
64    /// Present without a position more often than one might expect: a
65    /// receiver with a running real-time clock emits the time in every
66    /// RMC while it is still searching for satellites.
67    pub time: Option<DateTime>,
68    /// Whether [`time`](Self::time) accompanied a *valid* position.
69    ///
70    /// The distinction matters to a caller deciding how much to trust the
71    /// instant: a time that came with a fix was disciplined by the
72    /// satellites this second, and one that did not came from whatever
73    /// the receiver has been keeping on its own.
74    pub time_from_fix: bool,
75}
76
77impl Fix {
78    /// Whether the cycle produced a position at all.
79    pub const fn has_position(&self) -> bool {
80        self.latitude_e7.is_some() && self.longitude_e7.is_some()
81    }
82}
83
84/// Satellite counts accumulated across one constellation's GSV burst.
85#[derive(Clone, Copy, Default)]
86struct Constellation {
87    talker: [u8; 2],
88    in_view: u8,
89}
90
91/// Byte stream in, one [`Fix`] per cycle out.
92pub struct Driver {
93    assembler: Assembler,
94    /// The cycle being accumulated.
95    cycle: Cycle,
96}
97
98/// The parts of a cycle seen so far.
99#[derive(Clone, Copy, Default)]
100struct Cycle {
101    gga_quality: u8,
102    gga_sats_used: u8,
103    gga_altitude_m: Option<i32>,
104    gga_latitude: Option<i32>,
105    gga_longitude: Option<i32>,
106    gga_hdop_centi: Option<u16>,
107    gsa_fix_mode: u8,
108    hdop_centi: Option<u16>,
109    constellations: [Constellation; MAX_CONSTELLATIONS],
110    constellation_count: usize,
111    saw_gsv: bool,
112}
113
114impl Cycle {
115    /// Total satellites in view across every constellation heard from.
116    fn in_view(&self) -> Option<u8> {
117        self.saw_gsv.then(|| {
118            self.constellations[..self.constellation_count]
119                .iter()
120                .fold(0u8, |sum, entry| sum.saturating_add(entry.in_view))
121        })
122    }
123
124    /// Record one GSV. Only the burst's first message carries a count
125    /// worth reading; the rest repeat it, and adding them again would
126    /// multiply the total by the burst length.
127    fn absorb_gsv(&mut self, gsv: Gsv) {
128        if gsv.message != 1 {
129            return;
130        }
131        self.saw_gsv = true;
132        if let Some(entry) = self.constellations[..self.constellation_count]
133            .iter_mut()
134            .find(|entry| entry.talker == gsv.talker)
135        {
136            entry.in_view = gsv.in_view;
137            return;
138        }
139        if self.constellation_count < MAX_CONSTELLATIONS {
140            self.constellations[self.constellation_count] = Constellation {
141                talker: gsv.talker,
142                in_view: gsv.in_view,
143            };
144            self.constellation_count += 1;
145        }
146    }
147}
148
149impl Default for Driver {
150    fn default() -> Self {
151        Self::new()
152    }
153}
154
155impl Driver {
156    /// A fresh driver, waiting for the receiver's first sentence.
157    pub const fn new() -> Self {
158        Self {
159            assembler: Assembler::new(),
160            cycle: Cycle {
161                gga_quality: 0,
162                gga_sats_used: 0,
163                gga_altitude_m: None,
164                gga_latitude: None,
165                gga_longitude: None,
166                gga_hdop_centi: None,
167                gsa_fix_mode: 0,
168                hdop_centi: None,
169                constellations: [Constellation {
170                    talker: [0; 2],
171                    in_view: 0,
172                }; MAX_CONSTELLATIONS],
173                constellation_count: 0,
174                saw_gsv: false,
175            },
176        }
177    }
178
179    /// Discard all accumulated state.
180    ///
181    /// Call after powering the receiver: sentences from before a power
182    /// cycle describe where the device was, not where it is, and must not
183    /// join a cycle from after it.
184    pub fn reset(&mut self) {
185        self.assembler.reset();
186        self.cycle = Cycle::default();
187    }
188
189    /// Feed one byte, yielding a fix when a cycle completes.
190    pub fn push(&mut self, byte: u8) -> Option<Fix> {
191        let sentence = self.assembler.push(byte)?;
192        match sentence {
193            Sentence::Gga(gga) => {
194                self.cycle.gga_quality = gga.quality;
195                self.cycle.gga_sats_used = gga.sats_used;
196                self.cycle.gga_altitude_m = gga.altitude_m;
197                self.cycle.gga_latitude = gga.latitude;
198                self.cycle.gga_longitude = gga.longitude;
199                self.cycle.gga_hdop_centi = gga.hdop_centi;
200                None
201            }
202            Sentence::Gsa(gsa) => {
203                self.cycle.gsa_fix_mode = gsa.fix_mode;
204                // A receiver reporting no dilution has no dilution to
205                // report, so this clears rather than holding the last
206                // cycle's figure.
207                self.cycle.hdop_centi = gsa.hdop_centi;
208                None
209            }
210            Sentence::Gsv(gsv) => {
211                self.cycle.absorb_gsv(gsv);
212                None
213            }
214            Sentence::Rmc(rmc) => {
215                let cycle = core::mem::take(&mut self.cycle);
216                // RMC and GGA both carry a position and normally agree.
217                // Preferring RMC's is arbitrary but consistent; taking
218                // GGA's when RMC has none covers the cycle where a fix
219                // has just been acquired and only GGA reflects it yet.
220                let latitude = rmc.latitude.or(cycle.gga_latitude);
221                let longitude = rmc.longitude.or(cycle.gga_longitude);
222                let has_position = latitude.is_some() && longitude.is_some();
223
224                // The dimension comes from GSA when it said anything.
225                // Without GSA there is no dimension indicator anywhere in
226                // NMEA — GGA's quality field says *whether* the receiver
227                // is fixed, never in how many dimensions — so the presence
228                // of an altitude stands in for it. That is not a receiver
229                // that emits GSA and merely happened not to this cycle; it
230                // is one configured never to emit it at all, which is the
231                // shipping state of the AG3335 on some boards.
232                //
233                // Without a position there is no solution at all, whatever
234                // the indicators claim.
235                let quality = match (has_position, cycle.gsa_fix_mode, cycle.gga_quality) {
236                    (false, _, _) => FixQuality::None,
237                    (true, 3, _) => FixQuality::ThreeD,
238                    (true, 2, _) => FixQuality::TwoD,
239                    (true, _, 0) => FixQuality::None,
240                    (true, _, _) if cycle.gga_altitude_m.is_some() => FixQuality::ThreeD,
241                    (true, _, _) => FixQuality::TwoD,
242                };
243
244                Some(Fix {
245                    quality,
246                    // Half a coordinate pair is not a position. A
247                    // sentence carrying one and not the other is
248                    // malformed, and passing the half along would invite
249                    // somebody to treat it as a meridian.
250                    latitude_e7: has_position.then_some(latitude).flatten(),
251                    longitude_e7: has_position.then_some(longitude).flatten(),
252                    // Altitude belongs to a three-dimensional solution
253                    // and to nothing else: a receiver keeps emitting the
254                    // last one it computed as the solution degrades to
255                    // two dimensions, and reporting that as current would
256                    // be reporting an altitude nothing measured.
257                    altitude_m: match quality {
258                        FixQuality::ThreeD => cycle.gga_altitude_m,
259                        _ => None,
260                    },
261                    // GSA's figure when there was one, GGA's otherwise:
262                    // the two carry the same quantity, and a receiver may
263                    // emit either sentence without the other.
264                    hdop_centi: cycle.hdop_centi.or(cycle.gga_hdop_centi),
265                    sats_used: cycle.gga_sats_used,
266                    sats_in_view: cycle.in_view(),
267                    time: rmc.time,
268                    time_from_fix: rmc.valid,
269                })
270            }
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use core::fmt::Write as _;
279
280    /// Feed a checksummed sentence body, returning any completed fix.
281    fn feed(driver: &mut Driver, body: &str) -> Option<Fix> {
282        let checksum = body.bytes().fold(0u8, |sum, byte| sum ^ byte);
283        let mut line = heapless::String::<{ crate::nmea::MAX_SENTENCE }>::new();
284        line.push('$').unwrap();
285        line.push_str(body).unwrap();
286        write!(line, "*{checksum:02X}\r\n").unwrap();
287
288        let mut out = None;
289        for byte in line.bytes() {
290            if let Some(fix) = driver.push(byte) {
291                out = Some(fix);
292            }
293        }
294        out
295    }
296
297    /// One full cycle of a receiver with a three-dimensional solution.
298    fn three_d_cycle(driver: &mut Driver) -> Fix {
299        assert!(
300            feed(
301                driver,
302                "GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,"
303            )
304            .is_none(),
305            "GGA ended a cycle"
306        );
307        assert!(
308            feed(driver, "GPGSA,A,3,04,05,,09,12,,,24,,,,,2.5,1.3,2.1").is_none(),
309            "GSA ended a cycle"
310        );
311        assert!(
312            feed(driver, "GPGSV,3,1,11,03,03,111,00,04,15,270,00").is_none(),
313            "GSV ended a cycle"
314        );
315        feed(
316            driver,
317            "GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230326,003.1,W",
318        )
319        .expect("RMC did not end the cycle")
320    }
321
322    #[test]
323    fn a_cycle_assembles_into_one_fix() {
324        let mut driver = Driver::new();
325        let fix = three_d_cycle(&mut driver);
326        assert_eq!(fix.quality, FixQuality::ThreeD);
327        assert_eq!(fix.latitude_e7, Some(481_173_000));
328        assert_eq!(fix.longitude_e7, Some(115_166_666));
329        assert_eq!(fix.altitude_m, Some(545));
330        assert_eq!(fix.hdop_centi, Some(130));
331        assert_eq!(fix.sats_used, 8);
332        assert_eq!(fix.sats_in_view, Some(11));
333        assert!(fix.time_from_fix);
334        assert_eq!(fix.time.map(|at| at.hour), Some(12));
335        assert!(fix.has_position());
336    }
337
338    /// A cold receiver emits a full cycle of empty sentences before it
339    /// has anything. Nothing in it may read as a position.
340    #[test]
341    fn a_searching_receiver_produces_a_fix_with_nothing_in_it() {
342        let mut driver = Driver::new();
343        feed(&mut driver, "GPGGA,,,,,,0,00,,,M,,M,,");
344        feed(&mut driver, "GPGSA,A,1,,,,,,,,,,,,,,,");
345        let fix = feed(&mut driver, "GPRMC,,V,,,,,,,,,,N").expect("no fix emitted");
346        assert_eq!(fix.quality, FixQuality::None);
347        assert_eq!(fix.latitude_e7, None);
348        assert_eq!(fix.altitude_m, None);
349        assert_eq!(fix.sats_used, 0);
350        assert_eq!(fix.time, None);
351        assert!(!fix.time_from_fix);
352        assert!(!fix.has_position());
353    }
354
355    /// The case the whole receiver-RTC design rests on: a receiver that
356    /// knows what time it is and not where it is.
357    #[test]
358    fn time_without_a_fix_is_reported_and_marked_as_such() {
359        let mut driver = Driver::new();
360        let fix = feed(&mut driver, "GPRMC,081836.00,V,,,,,,,130926,,").expect("no fix emitted");
361        assert_eq!(
362            fix.time.map(|at| (at.year, at.month, at.day)),
363            Some((2026, 9, 13))
364        );
365        assert!(
366            !fix.time_from_fix,
367            "a time from a void fix must not claim satellite discipline"
368        );
369        assert!(!fix.has_position());
370        assert_eq!(fix.quality, FixQuality::None);
371    }
372
373    /// Altitude belongs to a three-dimensional solution. A receiver keeps
374    /// emitting the last one it computed as the solution degrades, and
375    /// reporting that as current would be reporting an altitude nothing
376    /// measured.
377    #[test]
378    fn a_two_dimensional_solution_drops_the_altitude() {
379        let mut driver = Driver::new();
380        feed(
381            &mut driver,
382            "GPGGA,123519,4807.038,N,01131.000,E,1,05,2.4,545.4,M,46.9,M,,",
383        );
384        feed(&mut driver, "GPGSA,A,2,04,05,,,,,,,,,,,4.1,2.4,3.1");
385        let fix = feed(
386            &mut driver,
387            "GPRMC,123519,A,4807.038,N,01131.000,E,,,230326,,",
388        )
389        .expect("no fix emitted");
390        assert_eq!(fix.quality, FixQuality::TwoD);
391        assert!(fix.has_position());
392        assert_eq!(fix.altitude_m, None);
393    }
394
395    /// Indicators claiming a fix while no sentence carries a position
396    /// describe a solution that does not exist.
397    #[test]
398    fn a_quality_indicator_without_a_position_is_not_a_fix() {
399        let mut driver = Driver::new();
400        feed(&mut driver, "GPGGA,123519,,,,,1,08,0.9,545.4,M,46.9,M,,");
401        feed(&mut driver, "GPGSA,A,3,04,05,,,,,,,,,,,2.5,1.3,2.1");
402        let fix = feed(&mut driver, "GPRMC,123519,A,,,,,,,230326,,").expect("no fix emitted");
403        assert_eq!(fix.quality, FixQuality::None);
404        assert_eq!(fix.altitude_m, None);
405    }
406
407    /// A receiver emitting GGA but no GSA still says whether it is fixed,
408    /// and its altitude is the only available evidence of a third axis.
409    #[test]
410    fn gga_alone_establishes_a_fix() {
411        let mut driver = Driver::new();
412        feed(
413            &mut driver,
414            "GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,",
415        );
416        let fix = feed(
417            &mut driver,
418            "GPRMC,123519,A,4807.038,N,01131.000,E,,,230326,,",
419        )
420        .expect("no fix emitted");
421        assert_eq!(fix.quality, FixQuality::ThreeD);
422        assert_eq!(fix.altitude_m, Some(545));
423        // GGA carries the same dilution figure GSA does.
424        assert_eq!(fix.hdop_centi, Some(90));
425    }
426
427    /// Without GSA and without an altitude there is nothing to suggest a
428    /// third axis, so the solution is flat.
429    #[test]
430    fn gga_without_an_altitude_is_two_dimensional() {
431        let mut driver = Driver::new();
432        feed(
433            &mut driver,
434            "GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,,M,46.9,M,,",
435        );
436        let fix = feed(
437            &mut driver,
438            "GPRMC,123519,A,4807.038,N,01131.000,E,,,230326,,",
439        )
440        .expect("no fix emitted");
441        assert_eq!(fix.quality, FixQuality::TwoD);
442        assert_eq!(fix.altitude_m, None);
443    }
444
445    /// GSA outranks the altitude heuristic: a receiver that says the
446    /// solution is flat is telling us something GGA cannot, and GGA's
447    /// altitude field may still hold the last figure it computed.
448    #[test]
449    fn gsa_outranks_a_lingering_gga_altitude() {
450        let mut driver = Driver::new();
451        feed(
452            &mut driver,
453            "GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,",
454        );
455        feed(&mut driver, "GPGSA,A,2,04,05,,,,,,,,,,,2.5,1.3,2.1");
456        let fix = feed(
457            &mut driver,
458            "GPRMC,123519,A,4807.038,N,01131.000,E,,,230326,,",
459        )
460        .expect("no fix emitted");
461        assert_eq!(fix.quality, FixQuality::TwoD);
462        assert_eq!(fix.altitude_m, None);
463        // GSA's figure wins where both are present.
464        assert_eq!(fix.hdop_centi, Some(130));
465    }
466
467    /// The T1000-E's AG3335 as it ships: GGA and RMC only, because the
468    /// receiver keeps its NMEA output selection in its own non-volatile
469    /// memory and the vendor firmware switched GSA and GSV off there.
470    /// Everything except the satellites-in-view count is still available,
471    /// and taking a 3D fix as 2D would cost the altitude too.
472    #[test]
473    fn an_ag3335_emitting_only_gga_and_rmc_still_reports_fully() {
474        let mut driver = Driver::new();
475        feed(
476            &mut driver,
477            "GNGGA,081519.000,4208.0416,N,12237.0543,W,1,10,1.04,698.3,M,-22.4,M,,",
478        );
479        let fix = feed(
480            &mut driver,
481            "GNRMC,081519.000,A,4208.0416,N,12237.0543,W,0.04,0.00,050826,,,A,V",
482        )
483        .expect("no fix emitted");
484
485        assert_eq!(fix.quality, FixQuality::ThreeD);
486        assert_eq!(fix.altitude_m, Some(698));
487        assert_eq!(fix.hdop_centi, Some(104));
488        assert_eq!(fix.sats_used, 10);
489        // Nothing reported it, so nothing is claimed.
490        assert_eq!(fix.sats_in_view, None);
491        assert!(fix.time_from_fix);
492        assert!(fix.has_position());
493    }
494
495    /// Each constellation counts only its own satellites, so a
496    /// multi-constellation receiver's bursts have to be summed — and the
497    /// repeat messages within a burst must not be summed again.
498    #[test]
499    fn satellites_in_view_sum_across_constellations_once_each() {
500        let mut driver = Driver::new();
501        feed(&mut driver, "GPGSV,3,1,11,03,03,111,00");
502        feed(&mut driver, "GPGSV,3,2,11,09,23,313,00");
503        feed(&mut driver, "GPGSV,3,3,11,24,58,065,00");
504        feed(&mut driver, "GLGSV,2,1,07,65,12,034,00");
505        feed(&mut driver, "GLGSV,2,2,07,66,45,120,00");
506        let fix = feed(&mut driver, "GPRMC,,V,,,,,,,,,,N").expect("no fix emitted");
507        assert_eq!(fix.sats_in_view, Some(18), "11 GPS + 7 GLONASS");
508
509        // A repeated first message from the same talker replaces rather
510        // than adds — the count is a property of the burst.
511        let mut driver = Driver::new();
512        feed(&mut driver, "GPGSV,1,1,05,03,03,111,00");
513        feed(&mut driver, "GPGSV,1,1,05,03,03,111,00");
514        let fix = feed(&mut driver, "GPRMC,,V,,,,,,,,,,N").expect("no fix emitted");
515        assert_eq!(fix.sats_in_view, Some(5));
516    }
517
518    /// A receiver that reports no GSV at all is not reporting zero
519    /// satellites in view; it is not reporting.
520    #[test]
521    fn no_gsv_means_no_answer_rather_than_zero() {
522        let mut driver = Driver::new();
523        let fix = feed(&mut driver, "GPRMC,,V,,,,,,,,,,N").expect("no fix emitted");
524        assert_eq!(fix.sats_in_view, None);
525    }
526
527    /// Each cycle stands alone: a fix does not inherit the previous
528    /// cycle's altitude, dilution, or satellite count.
529    #[test]
530    fn a_cycle_does_not_inherit_the_previous_one() {
531        let mut driver = Driver::new();
532        let first = three_d_cycle(&mut driver);
533        assert_eq!(first.altitude_m, Some(545));
534
535        // The receiver loses the sky entirely: nothing but a void RMC.
536        let second = feed(&mut driver, "GPRMC,,V,,,,,,,,,,N").expect("no fix emitted");
537        assert_eq!(second.quality, FixQuality::None);
538        assert_eq!(second.altitude_m, None);
539        assert_eq!(second.hdop_centi, None);
540        assert_eq!(second.sats_used, 0);
541        assert_eq!(second.sats_in_view, None);
542    }
543
544    /// Resetting is what a power cycle does: whatever the receiver said
545    /// about where it was must not attach itself to where it is.
546    #[test]
547    fn resetting_discards_a_partial_cycle() {
548        let mut driver = Driver::new();
549        feed(
550            &mut driver,
551            "GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,",
552        );
553        driver.reset();
554        let fix = feed(&mut driver, "GPRMC,,V,,,,,,,,,,N").expect("no fix emitted");
555        assert_eq!(fix.latitude_e7, None);
556        assert_eq!(fix.altitude_m, None);
557        assert_eq!(fix.sats_used, 0);
558    }
559
560    /// Garbage between sentences is the normal condition of a UART that
561    /// just came up, and must cost at most the sentence it landed in.
562    #[test]
563    fn a_corrupt_sentence_costs_only_itself() {
564        let mut driver = Driver::new();
565        for byte in b"\x00\xff$GPGGA,tor" {
566            driver.push(*byte);
567        }
568        feed(
569            &mut driver,
570            "GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,",
571        );
572        feed(&mut driver, "GPGSA,A,3,04,05,,,,,,,,,,,2.5,1.3,2.1");
573        let fix = feed(
574            &mut driver,
575            "GPRMC,123519,A,4807.038,N,01131.000,E,,,230326,,",
576        )
577        .expect("no fix emitted");
578        assert_eq!(fix.quality, FixQuality::ThreeD);
579        assert_eq!(fix.altitude_m, Some(545));
580    }
581}