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/// - `$` (NMEA) and `!` (AIS) 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 NMEA, `!` for AIS.
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/// # Examples
46///
47/// ```
48/// use nmea_kit::parse_frame;
49///
50/// let frame = parse_frame("$WIMWD,270.0,T,268.5,M,12.4,N,6.4,M*63").unwrap();
51/// assert_eq!(frame.prefix, '$');
52/// assert_eq!(frame.talker, "WI");
53/// assert_eq!(frame.sentence_type, "MWD");
54/// assert_eq!(frame.fields.len(), 8);
55/// ```
56pub fn parse_frame(line: &str) -> Result<NmeaFrame<'_>, FrameError> {
57    let line = line.trim();
58    if line.is_empty() {
59        return Err(FrameError::Empty);
60    }
61
62    // Strip IEC 61162-450 tag block: \tag:val,...*xx\SENTENCE
63    let (tag_block, line) = strip_tag_block(line)?;
64
65    // Extract prefix
66    let prefix = line.chars().next().ok_or(FrameError::Empty)?;
67    if prefix != '$' && prefix != '!' {
68        return Err(FrameError::InvalidPrefix(prefix));
69    }
70
71    let after_prefix = &line[1..];
72
73    // Split at checksum delimiter
74    let (body, checksum_str) = match after_prefix.rfind('*') {
75        Some(pos) => {
76            let body = &after_prefix[..pos];
77            let cs_str = after_prefix[pos + 1..].trim_end_matches(['\r', '\n']);
78            (body, Some(cs_str))
79        }
80        None => (after_prefix.trim_end_matches(['\r', '\n']), None),
81    };
82
83    // Validate checksum if present
84    if let Some(cs_str) = checksum_str {
85        // NMEA checksum is exactly two hex digits (either case). Reject malformed
86        // forms ('+1F', single 'A', '1FA') that u8::from_str_radix would otherwise accept.
87        if cs_str.len() != 2 || !cs_str.bytes().all(|b| b.is_ascii_hexdigit()) {
88            return Err(FrameError::MalformedChecksum);
89        }
90        let expected = u8::from_str_radix(cs_str, 16).map_err(|_| FrameError::MalformedChecksum)?;
91        let computed = body.bytes().fold(0u8, |acc, b| acc ^ b);
92        if expected != computed {
93            return Err(FrameError::BadChecksum { expected, computed });
94        }
95    }
96
97    // Extract talker (2 chars) + sentence type (3 chars)
98    if body.len() < 5 {
99        return Err(FrameError::TooShort);
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 {
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 XOR checksum and appends `*XX\r\n`.
151///
152/// Fields must not contain the `,` or `*` delimiters (except free-text such as TXT,
153/// which is inherently ambiguous on the wire); `encode_frame` does not escape them.
154///
155/// # Examples
156///
157/// ```
158/// use nmea_kit::encode_frame;
159///
160/// let sentence = encode_frame('$', "WI", "MWD", &["270.0", "T", "268.5", "M", "12.4", "N", "6.4", "M"]);
161/// assert!(sentence.starts_with("$WIMWD,"));
162/// assert!(sentence.ends_with("\r\n"));
163/// ```
164pub fn encode_frame(prefix: char, talker: &str, sentence_type: &str, fields: &[&str]) -> String {
165    let body = if fields.is_empty() {
166        format!("{talker}{sentence_type}")
167    } else {
168        format!("{talker}{sentence_type},{}", fields.join(","))
169    };
170
171    let checksum = body.bytes().fold(0u8, |acc, b| acc ^ b);
172    format!("{prefix}{body}*{checksum:02X}\r\n")
173}
174
175/// Strip an optional IEC 61162-450 tag block from the beginning of the line.
176/// Returns `(Option<tag_block_content>, remaining_line)`.
177fn strip_tag_block(line: &str) -> Result<(Option<&str>, &str), FrameError> {
178    if let Some(rest) = line.strip_prefix('\\') {
179        match rest.find('\\') {
180            Some(close) => {
181                let tag = &rest[..close];
182                let remaining = &rest[close + 1..];
183                Ok((Some(tag), remaining))
184            }
185            None => Err(FrameError::MalformedTagBlock),
186        }
187    } else {
188        Ok((None, line))
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn ais_multi_fragment_signalk() {
198        let frame1 = parse_frame(
199            "!AIVDM,2,1,0,A,53brRt4000010SG700iE@LE8@Tp4000000000153P615t0Ht0SCkjH4jC1C,0*25",
200        )
201        .expect("AIS fragment 1");
202        assert_eq!(frame1.prefix, '!');
203        assert_eq!(frame1.sentence_type, "VDM");
204        assert_eq!(frame1.fields[1], "1"); // fragment number
205    }
206
207    #[test]
208    fn apb_fixture_signalk() {
209        let frame =
210            parse_frame("$GPAPB,A,A,0.10,R,N,V,V,011,M,DEST,011,M,011,M*3C").expect("valid APB");
211        assert_eq!(frame.sentence_type, "APB");
212        assert_eq!(frame.fields[9], "DEST");
213    }
214
215    #[test]
216    fn dbt_sounder_gpsd() {
217        let frame =
218            parse_frame("$SDDBT,7.7,f,2.3,M,1.3,F*05").expect("valid DBT from GPSD sounder.log");
219        assert_eq!(frame.sentence_type, "DBT");
220        assert_eq!(frame.fields[2], "2.3"); // meters
221    }
222
223    #[test]
224    fn dpt_fixtures_signalk() {
225        let fixtures = [
226            ("$IIDPT,4.1,0.0*45", "4.1", "0.0"),
227            ("$IIDPT,4.1,1.0*44", "4.1", "1.0"),
228            ("$IIDPT,4.1,-1.0*69", "4.1", "-1.0"),
229        ];
230        for (fix, depth, offset) in &fixtures {
231            let frame = parse_frame(fix).unwrap_or_else(|e| panic!("failed to parse {fix}: {e}"));
232            assert_eq!(frame.sentence_type, "DPT");
233            assert_eq!(frame.fields[0], *depth);
234            assert_eq!(frame.fields[1], *offset);
235        }
236    }
237
238    #[test]
239    fn dpt_humminbird_gpsd() {
240        let frame = parse_frame("$INDPT,2.2,0.0*47").expect("valid DPT from GPSD humminbird");
241        assert_eq!(frame.talker, "IN");
242        assert_eq!(frame.sentence_type, "DPT");
243    }
244
245    #[test]
246    fn encode_no_fields() {
247        let result = encode_frame('$', "GP", "RMC", &[]);
248        assert!(result.starts_with("$GPRMC*"));
249    }
250
251    #[test]
252    fn encode_simple_sentence() {
253        let result = encode_frame(
254            '$',
255            "WI",
256            "MWD",
257            &["270.0", "T", "268.5", "M", "12.4", "N", "6.4", "M"],
258        );
259        assert!(result.starts_with("$WIMWD,270.0,T,268.5,M,12.4,N,6.4,M*"));
260        assert!(result.ends_with("\r\n"));
261        // Verify checksum is valid by re-parsing
262        let frame = parse_frame(result.trim()).expect("encoded sentence should be parseable");
263        assert_eq!(frame.sentence_type, "MWD");
264    }
265
266    #[test]
267    fn encode_with_empty_fields() {
268        let result = encode_frame(
269            '$',
270            "GP",
271            "APB",
272            &["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
273        );
274        let frame = parse_frame(result.trim()).expect("should re-parse");
275        assert_eq!(frame.sentence_type, "APB");
276        assert!(frame.fields.iter().all(|f| f.is_empty()));
277    }
278
279    #[test]
280    fn error_bad_checksum() {
281        assert!(matches!(
282            parse_frame("$GPRMC,175957.917,A*FF"),
283            Err(FrameError::BadChecksum { .. })
284        ));
285    }
286
287    #[test]
288    fn error_empty_input() {
289        assert_eq!(parse_frame(""), Err(FrameError::Empty));
290        assert_eq!(parse_frame("   "), Err(FrameError::Empty));
291    }
292
293    #[test]
294    fn error_invalid_prefix() {
295        assert!(matches!(
296            parse_frame("GPRMC,175957.917,A*00"),
297            Err(FrameError::InvalidPrefix('G'))
298        ));
299    }
300
301    #[test]
302    fn error_malformed_tag_block() {
303        assert_eq!(
304            parse_frame("\\s:FooBar$GPRMC,175957.917,A*00"),
305            Err(FrameError::MalformedTagBlock)
306        );
307    }
308
309    #[test]
310    fn error_too_short() {
311        assert_eq!(parse_frame("$GP*17"), Err(FrameError::TooShort));
312    }
313
314    #[test]
315    fn non_ascii_address_returns_err_not_panic() {
316        // Multi-byte UTF-8 in the address field must not panic the byte-index slice.
317        assert_eq!(parse_frame("$é12,foo"), Err(FrameError::NonAsciiAddress));
318        assert_eq!(parse_frame("$Aé,1,2"), Err(FrameError::NonAsciiAddress));
319    }
320
321    #[test]
322    fn hdg_fixtures_signalk() {
323        let frame = parse_frame("$INHDG,180,5,W,10,W*6D").expect("valid HDG");
324        assert_eq!(frame.sentence_type, "HDG");
325        assert_eq!(frame.fields[0], "180");
326        assert_eq!(frame.fields[1], "5");
327        assert_eq!(frame.fields[2], "W");
328    }
329
330    #[test]
331    fn hdt_saab_gpsd() {
332        let frame = parse_frame("$HEHDT,4.0,T*2B").expect("valid HDT from GPSD saab-r4");
333        assert_eq!(frame.talker, "HE");
334        assert_eq!(frame.sentence_type, "HDT");
335    }
336
337    #[test]
338    fn mtw_humminbird_gpsd() {
339        let frame = parse_frame("$INMTW,17.9,C*1B").expect("valid MTW from GPSD humminbird");
340        assert_eq!(frame.sentence_type, "MTW");
341        assert_eq!(frame.fields[0], "17.9");
342    }
343
344    #[test]
345    fn mwd_fixtures_signalk() {
346        // From SignalK test suite
347        let fixtures = [
348            "$IIMWD,,,046.,M,10.1,N,05.2,M*0B",
349            "$IIMWD,046.,T,046.,M,10.1,N,,*17",
350            "$IIMWD,046.,T,,,,,5.2,M*72",
351        ];
352        for fix in &fixtures {
353            let frame = parse_frame(fix).unwrap_or_else(|e| panic!("failed to parse {fix}: {e}"));
354            assert_eq!(frame.sentence_type, "MWD");
355        }
356    }
357
358    #[test]
359    fn parse_ais_sentence() {
360        let frame =
361            parse_frame("!AIVDM,1,1,,A,13u@Dt002s000000000000000000,0*60").expect("valid frame");
362        assert_eq!(frame.prefix, '!');
363        assert_eq!(frame.talker, "AI");
364        assert_eq!(frame.sentence_type, "VDM");
365        assert_eq!(frame.fields[0], "1");
366    }
367
368    #[test]
369    fn parse_depth_sentence() {
370        let frame = parse_frame("$SDDBT,7.7,f,2.3,M,1.3,F*05").expect("valid frame");
371        assert_eq!(frame.talker, "SD");
372        assert_eq!(frame.sentence_type, "DBT");
373        assert_eq!(frame.fields[2], "2.3");
374    }
375
376    #[test]
377    fn parse_empty_fields() {
378        let frame = parse_frame("$GPAPB,,,,,,,,,,,,,,*44").expect("valid frame");
379        assert_eq!(frame.sentence_type, "APB");
380        assert!(frame.fields.iter().all(|f| f.is_empty()));
381    }
382
383    #[test]
384    fn parse_multi_constellation_talker() {
385        // GN = multi-constellation GNSS
386        let frame =
387            parse_frame("$GNRMC,175957.917,A,3857.1234,N,07705.1234,W,0.0,0.0,010100,,,A*69")
388                .expect("valid frame");
389        assert_eq!(frame.talker, "GN");
390        assert_eq!(frame.sentence_type, "RMC");
391    }
392
393    #[test]
394    fn parse_no_checksum_accepted() {
395        let result = parse_frame("$GPRMC,175957.917,A,3857.1234,N,07705.1234,W,0.0,0.0,010100,,,A");
396        assert!(result.is_ok());
397    }
398
399    #[test]
400    fn parse_standard_nmea_sentence() {
401        let frame =
402            parse_frame("$GPRMC,175957.917,A,3857.1234,N,07705.1234,W,0.0,0.0,010100,,,A*77")
403                .expect("valid frame");
404        assert_eq!(frame.prefix, '$');
405        assert_eq!(frame.talker, "GP");
406        assert_eq!(frame.sentence_type, "RMC");
407        assert_eq!(frame.fields[0], "175957.917");
408        assert_eq!(frame.fields[1], "A");
409        assert_eq!(frame.tag_block, None);
410    }
411
412    #[test]
413    fn parse_wind_sentence() {
414        let frame = parse_frame("$WIMWD,270.0,T,268.5,M,12.4,N,6.4,M*63").expect("valid frame");
415        assert_eq!(frame.talker, "WI");
416        assert_eq!(frame.sentence_type, "MWD");
417        assert_eq!(frame.fields.len(), 8);
418        assert_eq!(frame.fields[0], "270.0");
419        assert_eq!(frame.fields[1], "T");
420    }
421
422    #[test]
423    fn parse_with_tag_block() {
424        let frame = parse_frame("\\s:FooBar,c:1234567890*xx\\$GPRMC,175957.917,A,3857.1234,N,07705.1234,W,0.0,0.0,010100,,,A*77").expect("valid frame");
425        assert!(frame.tag_block.is_some());
426        assert_eq!(frame.prefix, '$');
427        assert_eq!(frame.sentence_type, "RMC");
428    }
429
430    #[test]
431    fn single_trailing_comma_is_one_empty_field() {
432        // "$GPABC," (address + one trailing comma) is ONE empty field, not zero.
433        let f = parse_frame("$GPABC,*7B").expect("valid");
434        assert_eq!(f.fields, vec![""]);
435    }
436
437    #[test]
438    fn checksum_format_strictness() {
439        // Malformed checksum FORMATS are rejected (length/sign), regardless of XOR value.
440        assert_eq!(
441            parse_frame("$GPRMC,A*+1F"),
442            Err(FrameError::MalformedChecksum)
443        );
444        assert_eq!(
445            parse_frame("$GPRMC,A*A"),
446            Err(FrameError::MalformedChecksum)
447        );
448        assert_eq!(
449            parse_frame("$GPRMC,A*1FA"),
450            Err(FrameError::MalformedChecksum)
451        );
452        // Lowercase 2-digit hex is still accepted (real devices emit it).
453        let lower = "$GNRMC,103607.00,A,5327.03942,N,10214.42462,W,0.046,,060321,,,A,V*0e";
454        let upper = "$GNRMC,103607.00,A,5327.03942,N,10214.42462,W,0.046,,060321,,,A,V*0E";
455        assert!(parse_frame(lower).is_ok(), "lowercase *0e must parse");
456        assert!(parse_frame(upper).is_ok(), "uppercase *0E must parse");
457    }
458
459    #[test]
460    fn rot_saab_gpsd() {
461        let frame = parse_frame("$HEROT,0.0,A*2B").expect("valid ROT from GPSD saab-r4");
462        assert_eq!(frame.sentence_type, "ROT");
463    }
464
465    #[test]
466    fn roundtrip_parse_encode_parse() {
467        let original = "$WIMWD,270.0,T,268.5,M,12.4,N,6.4,M*63";
468        let frame1 = parse_frame(original).expect("parse original");
469        let encoded = encode_frame(
470            frame1.prefix,
471            frame1.talker,
472            frame1.sentence_type,
473            &frame1.fields,
474        );
475        let frame2 = parse_frame(encoded.trim()).expect("parse re-encoded");
476        assert_eq!(frame1.talker, frame2.talker);
477        assert_eq!(frame1.sentence_type, frame2.sentence_type);
478        assert_eq!(frame1.fields, frame2.fields);
479    }
480}