kenwood_thd75/sdcard/
gps_log.rs

1//! Parser for NMEA 0183 GPS log `.nme` files.
2//!
3//! The TH-D75 records GPS track logs in standard NMEA 0183 format.
4//! Each file contains a sequence of NMEA sentences, primarily `$GPRMC`
5//! (recommended minimum) and `$GPGGA` (fix data) with time, position,
6//! speed, course, and altitude.
7//!
8//! # Location
9//!
10//! `/KENWOOD/TH-D75/GPS_LOG/*.nme` — maximum 255 files per directory.
11//!
12//! # GPS Receiver mode (per Operating Tips §5.14.2)
13//!
14//! For prolonged GPS track logging, Menu No. 403 enables GPS Receiver
15//! mode, which disables the transceiver function to conserve battery.
16//! The FM broadcast radio remains functional in this mode.
17//!
18//! # Format
19//!
20//! Plain ASCII text, one NMEA sentence per line, terminated by `\r\n`.
21//! Each sentence starts with `$` and ends with `*HH` where HH is a
22//! two-digit hex XOR checksum of the bytes between `$` and `*`.
23//!
24//! # Supported Sentences
25//!
26//! | Sentence | Description |
27//! |----------|-------------|
28//! | `$GPRMC` | Recommended minimum: time, status, lat, lon, speed, course, date |
29//! | `$GPGGA` | Fix data: time, lat, lon, quality, satellites, HDOP, altitude |
30
31use super::SdCardError;
32
33/// A parsed GPS position from an NMEA sentence.
34///
35/// `None` when the GPS has no fix (void RMC or quality=0 GGA).
36pub type GpsPosition = Option<LatLon>;
37
38/// Latitude/longitude in decimal degrees.
39#[derive(Debug, Clone, PartialEq)]
40pub struct LatLon {
41    /// Latitude in decimal degrees (positive = N, negative = S).
42    pub latitude: f64,
43    /// Longitude in decimal degrees (positive = E, negative = W).
44    pub longitude: f64,
45}
46
47/// A single parsed NMEA RMC (Recommended Minimum) fix.
48///
49/// Contains the essential navigation data: time, position, speed,
50/// course, and date. This is the primary sentence type in TH-D75 GPS logs.
51#[derive(Debug, Clone, PartialEq)]
52pub struct RmcFix {
53    /// UTC time as `HHMMSS.sss` string (e.g., `"143025.000"`).
54    pub utc_time: String,
55    /// Fix status: `true` = valid (`A`), `false` = void (`V`).
56    pub valid: bool,
57    /// Position (latitude, longitude in decimal degrees).
58    pub position: GpsPosition,
59    /// Speed over ground in knots.
60    pub speed_knots: f64,
61    /// Course over ground in degrees true.
62    pub course_degrees: f64,
63    /// UTC date as `DDMMYY` string (e.g., `"030426"`).
64    pub date: String,
65}
66
67/// A single parsed NMEA GGA (Global Positioning System Fix Data) fix.
68///
69/// Adds altitude and satellite information not present in RMC.
70#[derive(Debug, Clone, PartialEq)]
71pub struct GgaFix {
72    /// UTC time as `HHMMSS.sss` string.
73    pub utc_time: String,
74    /// Position (latitude, longitude in decimal degrees).
75    pub position: GpsPosition,
76    /// GPS quality indicator (0=invalid, 1=GPS fix, 2=DGPS, etc.).
77    pub quality: u8,
78    /// Number of satellites in use.
79    pub satellites: u8,
80    /// Horizontal dilution of precision.
81    pub hdop: f64,
82    /// Altitude above mean sea level in metres.
83    pub altitude_m: f64,
84}
85
86/// A parsed NMEA sentence.
87#[derive(Debug, Clone, PartialEq)]
88pub enum NmeaSentence {
89    /// `$GPRMC` — Recommended Minimum (time, position, speed, course, date).
90    Rmc(RmcFix),
91    /// `$GPGGA` — Fix data (time, position, quality, satellites, altitude).
92    Gga(GgaFix),
93}
94
95/// A complete parsed GPS log file.
96#[derive(Debug, Clone)]
97pub struct GpsLog {
98    /// All successfully parsed sentences in file order.
99    pub sentences: Vec<NmeaSentence>,
100    /// Number of lines that failed to parse.
101    pub errors: usize,
102}
103
104impl GpsLog {
105    /// Return only RMC fixes, in file order.
106    #[must_use]
107    pub fn rmc_fixes(&self) -> Vec<&RmcFix> {
108        self.sentences
109            .iter()
110            .filter_map(|s| match s {
111                NmeaSentence::Rmc(fix) => Some(fix),
112                NmeaSentence::Gga(_) => None,
113            })
114            .collect()
115    }
116
117    /// Return only GGA fixes, in file order.
118    #[must_use]
119    pub fn gga_fixes(&self) -> Vec<&GgaFix> {
120        self.sentences
121            .iter()
122            .filter_map(|s| match s {
123                NmeaSentence::Gga(fix) => Some(fix),
124                NmeaSentence::Rmc(_) => None,
125            })
126            .collect()
127    }
128
129    /// Return only valid RMC fixes (status = 'A').
130    #[must_use]
131    pub fn valid_fixes(&self) -> Vec<&RmcFix> {
132        self.rmc_fixes().into_iter().filter(|f| f.valid).collect()
133    }
134}
135
136/// Parse an NMEA GPS log file from raw bytes.
137///
138/// Parses all `$GPRMC` and `$GPGGA` sentences. Unrecognised sentence
139/// types and malformed lines are silently skipped (counted in
140/// [`GpsLog::errors`]).
141///
142/// # Errors
143///
144/// Returns [`SdCardError::FileTooSmall`] only if the input is completely
145/// empty. Individual malformed sentences are skipped, not fatal.
146pub fn parse(data: &[u8]) -> Result<GpsLog, SdCardError> {
147    if data.is_empty() {
148        return Err(SdCardError::FileTooSmall {
149            expected: 1,
150            actual: 0,
151        });
152    }
153
154    let text = std::str::from_utf8(data).unwrap_or("");
155
156    // If UTF-8 failed, try as Latin-1 (every byte is valid)
157    let owned;
158    let text = if text.is_empty() && !data.is_empty() {
159        owned = data.iter().map(|&b| b as char).collect::<String>();
160        &owned
161    } else {
162        text
163    };
164
165    let mut sentences = Vec::new();
166    let mut errors = 0;
167
168    for line in text.lines() {
169        let line = line.trim();
170        if line.is_empty() || !line.starts_with('$') {
171            continue;
172        }
173
174        // Validate checksum
175        if !verify_checksum(line) {
176            errors += 1;
177            continue;
178        }
179
180        // Strip checksum suffix for parsing
181        let payload = line.find('*').map_or(line, |star| &line[..star]);
182
183        let fields: Vec<&str> = payload.split(',').collect();
184
185        match fields.first().copied() {
186            Some("$GPRMC" | "$GNRMC") => {
187                if let Some(fix) = parse_rmc(&fields) {
188                    sentences.push(NmeaSentence::Rmc(fix));
189                } else {
190                    errors += 1;
191                }
192            }
193            Some("$GPGGA" | "$GNGGA") => {
194                if let Some(fix) = parse_gga(&fields) {
195                    sentences.push(NmeaSentence::Gga(fix));
196                } else {
197                    errors += 1;
198                }
199            }
200            _ => {
201                // Unrecognised sentence type — skip silently
202            }
203        }
204    }
205
206    Ok(GpsLog { sentences, errors })
207}
208
209/// Verify the XOR checksum of an NMEA sentence.
210///
211/// The checksum covers all bytes between `$` and `*` (exclusive).
212fn verify_checksum(sentence: &str) -> bool {
213    let Some(star_pos) = sentence.find('*') else {
214        return false;
215    };
216
217    if star_pos < 1 || star_pos + 3 > sentence.len() {
218        return false;
219    }
220
221    let body = &sentence[1..star_pos];
222    let expected_hex = &sentence[star_pos + 1..star_pos + 3];
223
224    let computed: u8 = body.bytes().fold(0u8, |acc, b| acc ^ b);
225
226    let Ok(expected) = u8::from_str_radix(expected_hex, 16) else {
227        return false;
228    };
229
230    computed == expected
231}
232
233/// Parse NMEA latitude/longitude fields into decimal degrees.
234///
235/// NMEA format: `DDMM.MMMM` for lat, `DDDMM.MMMM` for lon.
236fn parse_coordinate(value: &str, hemisphere: &str) -> Option<f64> {
237    if value.is_empty() || hemisphere.is_empty() {
238        return None;
239    }
240
241    let dot_pos = value.find('.')?;
242    if dot_pos < 3 {
243        return None;
244    }
245
246    // Degrees are everything before the last 2 integer digits before the dot
247    let deg_end = dot_pos - 2;
248    let degrees: f64 = value[..deg_end].parse().ok()?;
249    let minutes: f64 = value[deg_end..].parse().ok()?;
250
251    let mut decimal = degrees + minutes / 60.0;
252
253    match hemisphere {
254        "S" | "W" => decimal = -decimal,
255        "N" | "E" => {}
256        _ => return None,
257    }
258
259    Some(decimal)
260}
261
262/// Parse a `$GPRMC` sentence.
263///
264/// `$GPRMC,time,status,lat,N/S,lon,E/W,speed,course,date,mag_var,E/W*cs`
265fn parse_rmc(fields: &[&str]) -> Option<RmcFix> {
266    if fields.len() < 10 {
267        return None;
268    }
269
270    let utc_time = fields[1].to_owned();
271    let valid = fields[2] == "A";
272
273    let position = match (
274        parse_coordinate(fields[3], fields[4]),
275        parse_coordinate(fields[5], fields[6]),
276    ) {
277        (Some(lat), Some(lon)) => Some(LatLon {
278            latitude: lat,
279            longitude: lon,
280        }),
281        _ => None,
282    };
283
284    let speed_knots = fields[7].parse().unwrap_or(0.0);
285    let course_degrees = fields[8].parse().unwrap_or(0.0);
286    let date = fields[9].to_owned();
287
288    Some(RmcFix {
289        utc_time,
290        valid,
291        position,
292        speed_knots,
293        course_degrees,
294        date,
295    })
296}
297
298/// Parse a `$GPGGA` sentence.
299///
300/// `$GPGGA,time,lat,N/S,lon,E/W,quality,sats,hdop,alt,M,geoid,M,age,ref*cs`
301fn parse_gga(fields: &[&str]) -> Option<GgaFix> {
302    if fields.len() < 10 {
303        return None;
304    }
305
306    let utc_time = fields[1].to_owned();
307
308    let position = match (
309        parse_coordinate(fields[2], fields[3]),
310        parse_coordinate(fields[4], fields[5]),
311    ) {
312        (Some(lat), Some(lon)) => Some(LatLon {
313            latitude: lat,
314            longitude: lon,
315        }),
316        _ => None,
317    };
318
319    let quality: u8 = fields[6].parse().unwrap_or(0);
320    let satellites: u8 = fields[7].parse().unwrap_or(0);
321    let hdop: f64 = fields[8].parse().unwrap_or(0.0);
322    let altitude_m: f64 = fields[9].parse().unwrap_or(0.0);
323
324    Some(GgaFix {
325        utc_time,
326        position,
327        quality,
328        satellites,
329        hdop,
330        altitude_m,
331    })
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn parse_valid_rmc() {
340        let sentence = "$GPRMC,143025.000,A,3545.1234,N,08234.5678,W,0.5,45.2,030426,,,A";
341        let cs: u8 = sentence[1..].bytes().fold(0u8, |acc, b| acc ^ b);
342        let line = format!("{sentence}*{cs:02X}\r\n");
343
344        let log = parse(line.as_bytes()).unwrap();
345        assert_eq!(log.sentences.len(), 1);
346
347        let NmeaSentence::Rmc(fix) = &log.sentences[0] else {
348            panic!("expected RMC");
349        };
350        assert!(fix.valid);
351        let pos = fix.position.as_ref().expect("should have position");
352        assert!((pos.latitude - 35.752_057).abs() < 0.001);
353        assert!((pos.longitude - (-82.575_463_333)).abs() < 0.001);
354        assert_eq!(fix.date, "030426");
355    }
356
357    #[test]
358    fn parse_valid_gga() {
359        let sentence = "$GPGGA,143025.000,3545.1234,N,08234.5678,W,1,08,1.2,345.6,M,0.0,M,,";
360        let cs: u8 = sentence[1..].bytes().fold(0u8, |acc, b| acc ^ b);
361        let line = format!("{sentence}*{cs:02X}\r\n");
362
363        let log = parse(line.as_bytes()).unwrap();
364        assert_eq!(log.sentences.len(), 1);
365
366        let NmeaSentence::Gga(fix) = &log.sentences[0] else {
367            panic!("expected GGA");
368        };
369        assert_eq!(fix.quality, 1);
370        assert_eq!(fix.satellites, 8);
371        assert!((fix.altitude_m - 345.6).abs() < 0.01);
372    }
373
374    #[test]
375    fn checksum_verification() {
376        assert!(verify_checksum("$GPGGA,,,,,,,,,*7A"));
377        assert!(!verify_checksum("$GPGGA,,,,,,,,,*00"));
378    }
379
380    #[test]
381    fn empty_file_returns_error() {
382        assert!(parse(b"").is_err());
383    }
384
385    #[test]
386    fn malformed_lines_counted_as_errors() {
387        let data = b"$GPRMC,bad,data*FF\r\n$NOTVALID*00\r\n";
388        let log = parse(data).unwrap();
389        assert!(log.sentences.is_empty());
390        assert!(log.errors > 0);
391    }
392
393    #[test]
394    fn void_rmc_parsed_but_not_valid() {
395        let sentence = "$GPRMC,143025.000,V,3545.1234,N,08234.5678,W,0.0,0.0,030426,,,N";
396        let cs: u8 = sentence[1..].bytes().fold(0u8, |acc, b| acc ^ b);
397        let line = format!("{sentence}*{cs:02X}\r\n");
398
399        let log = parse(line.as_bytes()).unwrap();
400        let fixes = log.valid_fixes();
401        assert!(fixes.is_empty());
402        assert_eq!(log.rmc_fixes().len(), 1);
403        assert!(!log.rmc_fixes()[0].valid);
404    }
405
406    #[test]
407    fn gnrmc_variant_accepted() {
408        let sentence = "$GNRMC,120000.000,A,3545.0000,N,08234.0000,W,0.0,0.0,010126,,,A";
409        let cs: u8 = sentence[1..].bytes().fold(0u8, |acc, b| acc ^ b);
410        let line = format!("{sentence}*{cs:02X}\r\n");
411
412        let log = parse(line.as_bytes()).unwrap();
413        assert_eq!(log.sentences.len(), 1);
414    }
415
416    #[test]
417    fn parse_real_d75_void_fixes() {
418        // Real NMEA captured from TH-D75 GPS (indoors, no fix)
419        let data = b"\
420$GPRMC,,V,,,,,,,,,,N*53\n\
421$GPGGA,,,,,,0,,,,,,,,*66\n\
422$GPRMC,,V,,,,,,,,,,N*53\n\
423$GPGGA,,,,,,0,,,,,,,,*66\n\
424$GPRMC,,V,,,,,,,,,,N*53\n\
425$GPGGA,,,,,,0,,,,,,,,*66\n";
426
427        let log = parse(data).unwrap();
428        // Void RMC has no coordinates — should be skipped by parser
429        // GGA with quality=0 has no coordinates — should be skipped
430        assert_eq!(log.errors, 0, "checksums should be valid");
431        // Void sentences have empty coordinate fields → parse_coordinate returns None
432        // so they won't produce Rmc/Gga entries
433        let valid = log.valid_fixes();
434        assert!(valid.is_empty(), "no valid fixes indoors");
435    }
436
437    #[test]
438    fn parse_real_d75_live_fix() {
439        use std::fmt::Write;
440
441        // Synthetic NMEA matching D75 format (real structure, fake coordinates)
442        // Build sentences with valid checksums
443        let rmc1 = "$GPRMC,120000.00,A,4052.1234,N,07356.5678,W,2.5,180.0,010126,5.2,E,A";
444        let gga1 = "$GPGGA,120000.00,4052.1234,N,07356.5678,W,1,07,1.2,250.5,M,-33.0,M,,";
445        let rmc2 = "$GPRMC,120001.00,A,4052.1300,N,07356.5700,W,0.0,0.0,010126,5.2,E,A";
446        let gga2 = "$GPGGA,120001.00,4052.1300,N,07356.5700,W,1,05,1.5,250.6,M,-33.0,M,,";
447
448        let mut data = String::new();
449        for s in [rmc1, gga1, rmc2, gga2] {
450            let cs: u8 = s[1..].bytes().fold(0u8, |acc, b| acc ^ b);
451            writeln!(data, "{s}*{cs:02X}").unwrap();
452        }
453
454        let log = parse(data.as_bytes()).unwrap();
455        assert_eq!(log.errors, 0, "all checksums valid");
456        assert_eq!(log.sentences.len(), 4);
457
458        let rmc = log.rmc_fixes();
459        assert_eq!(rmc.len(), 2);
460        assert!(rmc[0].valid);
461        assert_eq!(rmc[0].utc_time, "120000.00");
462        assert_eq!(rmc[0].date, "010126");
463
464        let pos = rmc[0].position.as_ref().expect("should have fix");
465        // 40°52.1234'N = 40.86872°N
466        assert!((pos.latitude - 40.8687).abs() < 0.001);
467        // 073°56.5678'W = -73.94280°W
468        assert!((pos.longitude - (-73.9428)).abs() < 0.001);
469        assert!((rmc[0].speed_knots - 2.5).abs() < 0.1);
470
471        let gga = log.gga_fixes();
472        assert_eq!(gga.len(), 2);
473        assert_eq!(gga[0].quality, 1);
474        assert_eq!(gga[0].satellites, 7);
475        assert!((gga[0].altitude_m - 250.5).abs() < 0.1);
476    }
477}