1use super::SdCardError;
32
33pub type GpsPosition = Option<LatLon>;
37
38#[derive(Debug, Clone, PartialEq)]
40pub struct LatLon {
41 pub latitude: f64,
43 pub longitude: f64,
45}
46
47#[derive(Debug, Clone, PartialEq)]
52pub struct RmcFix {
53 pub utc_time: String,
55 pub valid: bool,
57 pub position: GpsPosition,
59 pub speed_knots: f64,
61 pub course_degrees: f64,
63 pub date: String,
65}
66
67#[derive(Debug, Clone, PartialEq)]
71pub struct GgaFix {
72 pub utc_time: String,
74 pub position: GpsPosition,
76 pub quality: u8,
78 pub satellites: u8,
80 pub hdop: f64,
82 pub altitude_m: f64,
84}
85
86#[derive(Debug, Clone, PartialEq)]
88pub enum NmeaSentence {
89 Rmc(RmcFix),
91 Gga(GgaFix),
93}
94
95#[derive(Debug, Clone)]
97pub struct GpsLog {
98 pub sentences: Vec<NmeaSentence>,
100 pub errors: usize,
102}
103
104impl GpsLog {
105 #[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 #[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 #[must_use]
131 pub fn valid_fixes(&self) -> Vec<&RmcFix> {
132 self.rmc_fixes().into_iter().filter(|f| f.valid).collect()
133 }
134}
135
136pub 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 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 if !verify_checksum(line) {
176 errors += 1;
177 continue;
178 }
179
180 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 }
203 }
204 }
205
206 Ok(GpsLog { sentences, errors })
207}
208
209fn 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
233fn 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 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
262fn 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
298fn 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 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 assert_eq!(log.errors, 0, "checksums should be valid");
431 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 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 assert!((pos.latitude - 40.8687).abs() < 0.001);
467 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}