Skip to main content

nmea_kit/
frame.rs

1use crate::FrameError;
2
3/// A parsed NMEA 0183 frame with references into the original input.
4///
5/// The frame layer handles:
6/// - `$` (parametric) and `!` (encapsulation) prefix detection
7/// - IEC 61162-450 tag block stripping
8/// - XOR checksum validation
9/// - Talker ID + sentence type extraction
10/// - Proprietary sentence detection (`$P...`)
11/// - Field splitting by `,`
12///
13/// # Proprietary sentences
14///
15/// Per NMEA 0183, addresses starting with `P` are proprietary. For these,
16/// `talker` is `""` and `sentence_type` is the full address (e.g. `"PASHR"`,
17/// `"PSKPDPT"`). For standard sentences, `talker` is the 2-char talker ID
18/// and `sentence_type` is the 3-char type code.
19#[derive(Debug, Clone, PartialEq)]
20pub struct NmeaFrame<'a> {
21    /// Sentence prefix: `$` for parametric sentences, `!` for encapsulation sentences.
22    pub prefix: char,
23    /// Talker identifier (typically 2 letters, e.g. "GP", "WI", "AI").
24    /// Empty (`""`) for proprietary sentences (`$P...`).
25    pub talker: &'a str,
26    /// Sentence type. For standard sentences: 3 characters (e.g. "RMC", "MWD").
27    /// For proprietary sentences: the full address (e.g. "PASHR", "PSKPDPT").
28    pub sentence_type: &'a str,
29    /// Comma-separated payload fields (after talker+type, before checksum).
30    pub fields: Vec<&'a str>,
31    /// IEC 61162-450 tag block content, if present.
32    pub tag_block: Option<&'a str>,
33}
34
35/// Parse a raw NMEA 0183 line into a validated frame.
36///
37/// Handles both `$` (instrument) and `!` (AIS) sentences.
38/// Strips optional IEC 61162-450 tag blocks (`\...\` prefix).
39/// Validates XOR checksum when present.
40///
41/// Proprietary sentences (address starting with `P`) are detected
42/// automatically: `talker` will be `""` and `sentence_type` will
43/// contain the full address (e.g. `"PASHR"`, `"PSKPDPT"`).
44///
45/// Note: a `*` anywhere in the line is treated as the checksum delimiter (the
46/// last one wins). Free text containing `*` (e.g. TXT payloads) therefore
47/// requires a valid trailing checksum; without one the frame is rejected with
48/// [`FrameError::MalformedChecksum`].
49///
50/// # Examples
51///
52/// ```
53/// use nmea_kit::parse_frame;
54///
55/// let frame = parse_frame("$WIMWD,270.0,T,268.5,M,12.4,N,6.4,M*63").unwrap();
56/// assert_eq!(frame.prefix, '$');
57/// assert_eq!(frame.talker, "WI");
58/// assert_eq!(frame.sentence_type, "MWD");
59/// assert_eq!(frame.fields.len(), 8);
60/// ```
61pub 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    // Strip IEC 61162-450 tag block: \tag:val,...*xx\SENTENCE
68    let (tag_block, line) = strip_tag_block(line)?;
69
70    // Extract prefix
71    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    // Split at checksum delimiter
79    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    // Validate checksum if present
89    if let Some(cs_str) = checksum_str {
90        // NMEA checksum is exactly two hex digits (either case). Reject malformed
91        // forms ('+1F', single 'A', '1FA') that u8::from_str_radix would otherwise accept.
92        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    // Find the first comma to determine where the address field ends
103    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    // Address is ASCII by spec; guard the byte-index slices below against
111    // multi-byte UTF-8 that would otherwise panic on a non-char-boundary.
112    if !addr.is_ascii() {
113        return Err(FrameError::NonAsciiAddress);
114    }
115
116    // Proprietary sentences: address starts with 'P' (reserved per NMEA 0183).
117    // Standard sentences: first 2 chars = talker, last 3 chars = sentence type.
118    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    // Split remaining fields by comma
125    let fields_str = if addr_end < body.len() {
126        &body[addr_end + 1..]
127    } else {
128        ""
129    };
130
131    // A present-but-empty remainder (address followed by a comma) is one empty
132    // field; only the absence of any comma after the address means zero fields.
133    let fields: Vec<&str> = if addr_end < body.len() {
134        fields_str.split(',').collect() // yields [""] when fields_str is ""
135    } else {
136        Vec::new()
137    };
138
139    Ok(NmeaFrame {
140        prefix,
141        talker,
142        sentence_type,
143        fields,
144        tag_block,
145    })
146}
147
148/// Encode fields into a valid NMEA 0183 sentence string.
149///
150/// Computes the XOR checksum and appends `*XX\r\n`.
151///
152/// Returns an [`EncodeError`](crate::EncodeError) when the prefix is not `$`/`!`,
153/// the talker or sentence type is not ASCII, the sentence type is empty, or a field
154/// contains `,`, `*`, `\r`, `\n`, or a non-ASCII character.
155///
156/// # Examples
157///
158/// ```
159/// use nmea_kit::encode_frame;
160///
161/// let sentence = encode_frame('$', "WI", "MWD", &["270.0", "T", "268.5", "M", "12.4", "N", "6.4", "M"]).expect("valid");
162/// assert!(sentence.starts_with("$WIMWD,"));
163/// assert!(sentence.ends_with("\r\n"));
164/// ```
165pub 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
194/// Validate a single NMEA field before it is placed on the wire.
195pub(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
205/// Strip an optional IEC 61162-450 tag block from the beginning of the line.
206/// Returns `(Option<tag_block_content>, remaining_line)`.
207///
208/// When the tag block carries a checksum (`\tags*hh\`), it is validated (XOR
209/// over the content before `*`, same algorithm as the sentence checksum) and
210/// the exposed content excludes the `*hh` suffix. A tag block without a
211/// checksum is accepted as-is (the checksum is optional per IEC 61162-450).
212fn 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"); // fragment number
256    }
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"); // meters
272    }
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        // Verify checksum is valid by re-parsing
314        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        // Multi-byte UTF-8 in the address field must not panic the byte-index slice.
434        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        // From SignalK test suite
464        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        // GN = multi-constellation GNSS
503        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        // "$GPABC," (address + one trailing comma) is ONE empty field, not zero.
555        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        // Malformed checksum FORMATS are rejected (length/sign), regardless of XOR value.
614        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        // Lowercase 2-digit hex is still accepted (real devices emit it).
627        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}