1use crate::FrameError;
2
3#[derive(Debug, Clone, PartialEq)]
20pub struct NmeaFrame<'a> {
21 pub prefix: char,
23 pub talker: &'a str,
26 pub sentence_type: &'a str,
29 pub fields: Vec<&'a str>,
31 pub tag_block: Option<&'a str>,
33}
34
35pub fn parse_frame(line: &str) -> Result<NmeaFrame<'_>, FrameError> {
62 let line = line.trim();
63 if line.is_empty() {
64 return Err(FrameError::Empty);
65 }
66
67 let (tag_block, line) = strip_tag_block(line)?;
69
70 let prefix = line.chars().next().ok_or(FrameError::TooShort)?;
72 if prefix != '$' && prefix != '!' {
73 return Err(FrameError::InvalidPrefix(prefix));
74 }
75
76 let after_prefix = &line[1..];
77
78 let (body, checksum_str) = match after_prefix.rfind('*') {
80 Some(pos) => {
81 let body = &after_prefix[..pos];
82 let cs_str = after_prefix[pos + 1..].trim_end_matches(['\r', '\n']);
83 (body, Some(cs_str))
84 }
85 None => (after_prefix.trim_end_matches(['\r', '\n']), None),
86 };
87
88 if let Some(cs_str) = checksum_str {
90 if cs_str.len() != 2 || !cs_str.bytes().all(|b| b.is_ascii_hexdigit()) {
93 return Err(FrameError::MalformedChecksum);
94 }
95 let expected = u8::from_str_radix(cs_str, 16).map_err(|_| FrameError::MalformedChecksum)?;
96 let computed = body.bytes().fold(0u8, |acc, b| acc ^ b);
97 if expected != computed {
98 return Err(FrameError::BadChecksum { expected, computed });
99 }
100 }
101
102 let addr_end = body.find(',').unwrap_or(body.len());
104 let addr = &body[..addr_end];
105
106 if addr.len() < 3 || (addr.starts_with('P') && addr.len() < 4) {
107 return Err(FrameError::TooShort);
108 }
109
110 if !addr.is_ascii() {
113 return Err(FrameError::NonAsciiAddress);
114 }
115
116 let (talker, sentence_type) = if addr.starts_with('P') {
119 ("", addr)
120 } else {
121 (&addr[..addr.len() - 3], &addr[addr.len() - 3..])
122 };
123
124 let fields_str = if addr_end < body.len() {
126 &body[addr_end + 1..]
127 } else {
128 ""
129 };
130
131 let fields: Vec<&str> = if addr_end < body.len() {
134 fields_str.split(',').collect() } else {
136 Vec::new()
137 };
138
139 Ok(NmeaFrame {
140 prefix,
141 talker,
142 sentence_type,
143 fields,
144 tag_block,
145 })
146}
147
148pub fn encode_frame(
166 prefix: char,
167 talker: &str,
168 sentence_type: &str,
169 fields: &[&str],
170) -> Result<String, crate::EncodeError> {
171 if prefix != '$' && prefix != '!' {
172 return Err(crate::EncodeError::InvalidPrefix(prefix));
173 }
174 if !talker.is_ascii() || !sentence_type.is_ascii() {
175 return Err(crate::EncodeError::NonAsciiAddress);
176 }
177 if sentence_type.is_empty() {
178 return Err(crate::EncodeError::EmptySentenceType);
179 }
180 for field in fields {
181 validate_field(field)?;
182 }
183
184 let body = if fields.is_empty() {
185 format!("{talker}{sentence_type}")
186 } else {
187 format!("{talker}{sentence_type},{}", fields.join(","))
188 };
189
190 let checksum = body.bytes().fold(0u8, |acc, b| acc ^ b);
191 Ok(format!("{prefix}{body}*{checksum:02X}\r\n"))
192}
193
194pub(crate) fn validate_field(field: &str) -> Result<(), crate::EncodeError> {
196 if let Some(c) = field
197 .chars()
198 .find(|&c| !c.is_ascii() || matches!(c, ',' | '*' | '\r' | '\n'))
199 {
200 return Err(crate::EncodeError::InvalidFieldCharacter(c));
201 }
202 Ok(())
203}
204
205fn strip_tag_block(line: &str) -> Result<(Option<&str>, &str), FrameError> {
213 if let Some(rest) = line.strip_prefix('\\') {
214 match rest.find('\\') {
215 Some(close) => {
216 let tag = &rest[..close];
217 let remaining = &rest[close + 1..];
218 let (content, checksum) = match tag.find('*') {
219 Some(pos) => (&tag[..pos], Some(&tag[pos + 1..])),
220 None => (tag, None),
221 };
222 if let Some(checksum) = checksum {
223 if checksum.len() != 2 || !checksum.bytes().all(|byte| byte.is_ascii_hexdigit())
224 {
225 return Err(FrameError::MalformedTagBlock);
226 }
227 let expected = u8::from_str_radix(checksum, 16)
228 .map_err(|_| FrameError::MalformedTagBlock)?;
229 let computed = content.bytes().fold(0u8, |acc, byte| acc ^ byte);
230 if expected != computed {
231 return Err(FrameError::BadTagChecksum { expected, computed });
232 }
233 }
234 Ok((Some(content), remaining))
235 }
236 None => Err(FrameError::MalformedTagBlock),
237 }
238 } else {
239 Ok((None, line))
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn ais_multi_fragment_signalk() {
249 let frame1 = parse_frame(
250 "!AIVDM,2,1,0,A,53brRt4000010SG700iE@LE8@Tp4000000000153P615t0Ht0SCkjH4jC1C,0*25",
251 )
252 .expect("AIS fragment 1");
253 assert_eq!(frame1.prefix, '!');
254 assert_eq!(frame1.sentence_type, "VDM");
255 assert_eq!(frame1.fields[1], "1"); }
257
258 #[test]
259 fn apb_fixture_signalk() {
260 let frame =
261 parse_frame("$GPAPB,A,A,0.10,R,N,V,V,011,M,DEST,011,M,011,M*3C").expect("valid APB");
262 assert_eq!(frame.sentence_type, "APB");
263 assert_eq!(frame.fields[9], "DEST");
264 }
265
266 #[test]
267 fn dbt_sounder_gpsd() {
268 let frame =
269 parse_frame("$SDDBT,7.7,f,2.3,M,1.3,F*05").expect("valid DBT from GPSD sounder.log");
270 assert_eq!(frame.sentence_type, "DBT");
271 assert_eq!(frame.fields[2], "2.3"); }
273
274 #[test]
275 fn dpt_fixtures_signalk() {
276 let fixtures = [
277 ("$IIDPT,4.1,0.0*45", "4.1", "0.0"),
278 ("$IIDPT,4.1,1.0*44", "4.1", "1.0"),
279 ("$IIDPT,4.1,-1.0*69", "4.1", "-1.0"),
280 ];
281 for (fix, depth, offset) in &fixtures {
282 let frame = parse_frame(fix).unwrap_or_else(|e| panic!("failed to parse {fix}: {e}"));
283 assert_eq!(frame.sentence_type, "DPT");
284 assert_eq!(frame.fields[0], *depth);
285 assert_eq!(frame.fields[1], *offset);
286 }
287 }
288
289 #[test]
290 fn dpt_humminbird_gpsd() {
291 let frame = parse_frame("$INDPT,2.2,0.0*47").expect("valid DPT from GPSD humminbird");
292 assert_eq!(frame.talker, "IN");
293 assert_eq!(frame.sentence_type, "DPT");
294 }
295
296 #[test]
297 fn encode_no_fields() {
298 let result = encode_frame('$', "GP", "RMC", &[]).expect("encode");
299 assert!(result.starts_with("$GPRMC*"));
300 }
301
302 #[test]
303 fn encode_simple_sentence() {
304 let result = encode_frame(
305 '$',
306 "WI",
307 "MWD",
308 &["270.0", "T", "268.5", "M", "12.4", "N", "6.4", "M"],
309 )
310 .expect("encode");
311 assert!(result.starts_with("$WIMWD,270.0,T,268.5,M,12.4,N,6.4,M*"));
312 assert!(result.ends_with("\r\n"));
313 let frame = parse_frame(result.trim()).expect("encoded sentence should be parseable");
315 assert_eq!(frame.sentence_type, "MWD");
316 }
317
318 #[test]
319 fn encode_with_empty_fields() {
320 let result = encode_frame(
321 '$',
322 "GP",
323 "APB",
324 &["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
325 )
326 .expect("encode");
327 let frame = parse_frame(result.trim()).expect("should re-parse");
328 assert_eq!(frame.sentence_type, "APB");
329 assert!(frame.fields.iter().all(|f| f.is_empty()));
330 }
331
332 #[test]
333 fn encode_frame_rejects_invalid_prefix() {
334 assert_eq!(
335 encode_frame('X', "GP", "RMC", &[]),
336 Err(crate::EncodeError::InvalidPrefix('X'))
337 );
338 }
339
340 #[test]
341 fn encode_frame_rejects_non_ascii_address() {
342 assert_eq!(
343 encode_frame('$', "G\u{e9}", "RMC", &["1"]),
344 Err(crate::EncodeError::NonAsciiAddress)
345 );
346 }
347
348 #[test]
349 fn encode_frame_rejects_empty_sentence_type() {
350 assert_eq!(
351 encode_frame('$', "GP", "", &[]),
352 Err(crate::EncodeError::EmptySentenceType)
353 );
354 }
355
356 #[test]
357 fn encode_frame_rejects_bad_field_characters() {
358 assert_eq!(
359 encode_frame('$', "GP", "TXT", &["a,b"]),
360 Err(crate::EncodeError::InvalidFieldCharacter(','))
361 );
362 assert_eq!(
363 encode_frame('$', "GP", "TXT", &["a\rb"]),
364 Err(crate::EncodeError::InvalidFieldCharacter('\r'))
365 );
366 assert!(matches!(
367 encode_frame('$', "GP", "TXT", &["\u{b0}"]),
368 Err(crate::EncodeError::InvalidFieldCharacter(_))
369 ));
370 }
371
372 #[test]
373 fn encode_frame_roundtrips_valid_input() {
374 let s = encode_frame('$', "WI", "MWD", &["270.0", "T"]).expect("encode");
375 let frame = parse_frame(s.trim()).expect("re-parse");
376 assert_eq!(frame.talker, "WI");
377 assert_eq!(frame.sentence_type, "MWD");
378 assert_eq!(frame.fields, vec!["270.0", "T"]);
379 }
380
381 #[test]
382 fn error_bad_checksum() {
383 assert!(matches!(
384 parse_frame("$GPRMC,175957.917,A*FF"),
385 Err(FrameError::BadChecksum { .. })
386 ));
387 }
388
389 #[test]
390 fn error_empty_input() {
391 assert_eq!(parse_frame(""), Err(FrameError::Empty));
392 assert_eq!(parse_frame(" "), Err(FrameError::Empty));
393 }
394
395 #[test]
396 fn error_invalid_prefix() {
397 assert!(matches!(
398 parse_frame("GPRMC,175957.917,A*00"),
399 Err(FrameError::InvalidPrefix('G'))
400 ));
401 }
402
403 #[test]
404 fn error_malformed_tag_block() {
405 assert_eq!(
406 parse_frame("\\s:FooBar$GPRMC,175957.917,A*00"),
407 Err(FrameError::MalformedTagBlock)
408 );
409 }
410
411 #[test]
412 fn error_too_short() {
413 assert_eq!(parse_frame("$GP*17"), Err(FrameError::TooShort));
414 }
415
416 #[test]
417 fn fieldless_proprietary_address_accepted() {
418 let frame = parse_frame("$PABC*10").expect("valid field-less proprietary");
419 assert_eq!(frame.talker, "");
420 assert_eq!(frame.sentence_type, "PABC");
421 assert!(frame.fields.is_empty());
422 }
423
424 #[test]
425 fn fieldless_talkerless_standard_accepted() {
426 let frame = parse_frame("$RMC*5C").expect("valid field-less talkerless");
427 assert_eq!(frame.talker, "");
428 assert_eq!(frame.sentence_type, "RMC");
429 }
430
431 #[test]
432 fn non_ascii_address_returns_err_not_panic() {
433 assert_eq!(parse_frame("$é12,foo"), Err(FrameError::NonAsciiAddress));
435 assert_eq!(parse_frame("$Aé,1,2"), Err(FrameError::NonAsciiAddress));
436 }
437
438 #[test]
439 fn hdg_fixtures_signalk() {
440 let frame = parse_frame("$INHDG,180,5,W,10,W*6D").expect("valid HDG");
441 assert_eq!(frame.sentence_type, "HDG");
442 assert_eq!(frame.fields[0], "180");
443 assert_eq!(frame.fields[1], "5");
444 assert_eq!(frame.fields[2], "W");
445 }
446
447 #[test]
448 fn hdt_saab_gpsd() {
449 let frame = parse_frame("$HEHDT,4.0,T*2B").expect("valid HDT from GPSD saab-r4");
450 assert_eq!(frame.talker, "HE");
451 assert_eq!(frame.sentence_type, "HDT");
452 }
453
454 #[test]
455 fn mtw_humminbird_gpsd() {
456 let frame = parse_frame("$INMTW,17.9,C*1B").expect("valid MTW from GPSD humminbird");
457 assert_eq!(frame.sentence_type, "MTW");
458 assert_eq!(frame.fields[0], "17.9");
459 }
460
461 #[test]
462 fn mwd_fixtures_signalk() {
463 let fixtures = [
465 "$IIMWD,,,046.,M,10.1,N,05.2,M*0B",
466 "$IIMWD,046.,T,046.,M,10.1,N,,*17",
467 "$IIMWD,046.,T,,,,,5.2,M*72",
468 ];
469 for fix in &fixtures {
470 let frame = parse_frame(fix).unwrap_or_else(|e| panic!("failed to parse {fix}: {e}"));
471 assert_eq!(frame.sentence_type, "MWD");
472 }
473 }
474
475 #[test]
476 fn parse_ais_sentence() {
477 let frame =
478 parse_frame("!AIVDM,1,1,,A,13u@Dt002s000000000000000000,0*60").expect("valid frame");
479 assert_eq!(frame.prefix, '!');
480 assert_eq!(frame.talker, "AI");
481 assert_eq!(frame.sentence_type, "VDM");
482 assert_eq!(frame.fields[0], "1");
483 }
484
485 #[test]
486 fn parse_depth_sentence() {
487 let frame = parse_frame("$SDDBT,7.7,f,2.3,M,1.3,F*05").expect("valid frame");
488 assert_eq!(frame.talker, "SD");
489 assert_eq!(frame.sentence_type, "DBT");
490 assert_eq!(frame.fields[2], "2.3");
491 }
492
493 #[test]
494 fn parse_empty_fields() {
495 let frame = parse_frame("$GPAPB,,,,,,,,,,,,,,*44").expect("valid frame");
496 assert_eq!(frame.sentence_type, "APB");
497 assert!(frame.fields.iter().all(|f| f.is_empty()));
498 }
499
500 #[test]
501 fn parse_multi_constellation_talker() {
502 let frame =
504 parse_frame("$GNRMC,175957.917,A,3857.1234,N,07705.1234,W,0.0,0.0,010100,,,A*69")
505 .expect("valid frame");
506 assert_eq!(frame.talker, "GN");
507 assert_eq!(frame.sentence_type, "RMC");
508 }
509
510 #[test]
511 fn parse_no_checksum_accepted() {
512 let result = parse_frame("$GPRMC,175957.917,A,3857.1234,N,07705.1234,W,0.0,0.0,010100,,,A");
513 assert!(result.is_ok());
514 }
515
516 #[test]
517 fn parse_standard_nmea_sentence() {
518 let frame =
519 parse_frame("$GPRMC,175957.917,A,3857.1234,N,07705.1234,W,0.0,0.0,010100,,,A*77")
520 .expect("valid frame");
521 assert_eq!(frame.prefix, '$');
522 assert_eq!(frame.talker, "GP");
523 assert_eq!(frame.sentence_type, "RMC");
524 assert_eq!(frame.fields[0], "175957.917");
525 assert_eq!(frame.fields[1], "A");
526 assert_eq!(frame.tag_block, None);
527 }
528
529 #[test]
530 fn parse_wind_sentence() {
531 let frame = parse_frame("$WIMWD,270.0,T,268.5,M,12.4,N,6.4,M*63").expect("valid frame");
532 assert_eq!(frame.talker, "WI");
533 assert_eq!(frame.sentence_type, "MWD");
534 assert_eq!(frame.fields.len(), 8);
535 assert_eq!(frame.fields[0], "270.0");
536 assert_eq!(frame.fields[1], "T");
537 }
538
539 #[test]
540 fn parse_with_tag_block() {
541 let content = "s:FooBar,c:1234567890";
542 let checksum = content.bytes().fold(0u8, |acc, byte| acc ^ byte);
543 let line = format!(
544 "\\{content}*{checksum:02X}\\$GPRMC,175957.917,A,3857.1234,N,07705.1234,W,0.0,0.0,010100,,,A*77"
545 );
546 let frame = parse_frame(&line).expect("valid frame");
547 assert_eq!(frame.tag_block, Some(content));
548 assert_eq!(frame.prefix, '$');
549 assert_eq!(frame.sentence_type, "RMC");
550 }
551
552 #[test]
553 fn single_trailing_comma_is_one_empty_field() {
554 let f = parse_frame("$GPABC,*7B").expect("valid");
556 assert_eq!(f.fields, vec![""]);
557 }
558
559 #[test]
560 fn short_proprietary_address_rejected() {
561 assert_eq!(parse_frame("$PAB*53"), Err(FrameError::TooShort));
562 }
563
564 #[test]
565 fn tag_block_bad_checksum_rejected() {
566 let content = "s:FooBar";
567 let checksum = content.bytes().fold(0u8, |acc, b| acc ^ b) ^ 0xFF;
568 let line = format!("\\{content}*{checksum:02X}\\$GPRMC,175957.917,A");
569 assert!(matches!(
570 parse_frame(&line),
571 Err(FrameError::BadTagChecksum { .. })
572 ));
573 }
574
575 #[test]
576 fn tag_block_malformed_checksum_rejected() {
577 assert_eq!(
578 parse_frame("\\s:FooBar*xx\\$GPRMC,175957.917,A"),
579 Err(FrameError::MalformedTagBlock)
580 );
581 assert_eq!(
582 parse_frame("\\s:FooBar*1\\$GPRMC,175957.917,A"),
583 Err(FrameError::MalformedTagBlock)
584 );
585 }
586
587 #[test]
588 fn tag_block_valid_checksum_accepted_and_stripped() {
589 let content = "s:FooBar,c:1234567890";
590 let checksum = content.bytes().fold(0u8, |acc, b| acc ^ b);
591 let line = format!(
592 "\\{content}*{checksum:02X}\\$GPRMC,175957.917,A,3857.1234,N,07705.1234,W,0.0,0.0,010100,,,A*77"
593 );
594 let frame = parse_frame(&line).expect("valid tag block checksum");
595 assert_eq!(frame.tag_block, Some(content));
596 }
597
598 #[test]
599 fn tag_block_without_checksum_accepted() {
600 let frame = parse_frame("\\s:FooBar\\$GPRMC,175957.917,A").expect("no tag checksum");
601 assert_eq!(frame.tag_block, Some("s:FooBar"));
602 }
603
604 #[test]
605 fn tag_block_without_sentence_is_too_short() {
606 assert_eq!(parse_frame("\\s:foo\\"), Err(FrameError::TooShort));
607 assert_eq!(parse_frame(""), Err(FrameError::Empty));
608 assert_eq!(parse_frame(" "), Err(FrameError::Empty));
609 }
610
611 #[test]
612 fn checksum_format_strictness() {
613 assert_eq!(
615 parse_frame("$GPRMC,A*+1F"),
616 Err(FrameError::MalformedChecksum)
617 );
618 assert_eq!(
619 parse_frame("$GPRMC,A*A"),
620 Err(FrameError::MalformedChecksum)
621 );
622 assert_eq!(
623 parse_frame("$GPRMC,A*1FA"),
624 Err(FrameError::MalformedChecksum)
625 );
626 let lower = "$GNRMC,103607.00,A,5327.03942,N,10214.42462,W,0.046,,060321,,,A,V*0e";
628 let upper = "$GNRMC,103607.00,A,5327.03942,N,10214.42462,W,0.046,,060321,,,A,V*0E";
629 assert!(parse_frame(lower).is_ok(), "lowercase *0e must parse");
630 assert!(parse_frame(upper).is_ok(), "uppercase *0E must parse");
631 }
632
633 #[test]
634 fn rot_saab_gpsd() {
635 let frame = parse_frame("$HEROT,0.0,A*2B").expect("valid ROT from GPSD saab-r4");
636 assert_eq!(frame.sentence_type, "ROT");
637 }
638
639 #[test]
640 fn roundtrip_parse_encode_parse() {
641 let original = "$WIMWD,270.0,T,268.5,M,12.4,N,6.4,M*63";
642 let frame1 = parse_frame(original).expect("parse original");
643 let encoded = encode_frame(
644 frame1.prefix,
645 frame1.talker,
646 frame1.sentence_type,
647 &frame1.fields,
648 )
649 .expect("encode");
650 let frame2 = parse_frame(encoded.trim()).expect("parse re-encoded");
651 assert_eq!(frame1.talker, frame2.talker);
652 assert_eq!(frame1.sentence_type, frame2.sentence_type);
653 assert_eq!(frame1.fields, frame2.fields);
654 }
655}