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        let expected = u8::from_str_radix(cs_str, 16).map_err(|_| FrameError::MalformedChecksum)?;
86        let computed = body.bytes().fold(0u8, |acc, b| acc ^ b);
87        if expected != computed {
88            return Err(FrameError::BadChecksum { expected, computed });
89        }
90    }
91
92    // Extract talker (2 chars) + sentence type (3 chars)
93    if body.len() < 5 {
94        return Err(FrameError::TooShort);
95    }
96
97    // Find the first comma to determine where the address field ends
98    let addr_end = body.find(',').unwrap_or(body.len());
99    let addr = &body[..addr_end];
100
101    if addr.len() < 3 {
102        return Err(FrameError::TooShort);
103    }
104
105    // Proprietary sentences: address starts with 'P' (reserved per NMEA 0183).
106    // Standard sentences: first 2 chars = talker, last 3 chars = sentence type.
107    let (talker, sentence_type) = if addr.starts_with('P') {
108        ("", addr)
109    } else {
110        (&addr[..addr.len() - 3], &addr[addr.len() - 3..])
111    };
112
113    // Split remaining fields by comma
114    let fields_str = if addr_end < body.len() {
115        &body[addr_end + 1..]
116    } else {
117        ""
118    };
119
120    let fields: Vec<&str> = if fields_str.is_empty() {
121        Vec::new()
122    } else {
123        fields_str.split(',').collect()
124    };
125
126    Ok(NmeaFrame {
127        prefix,
128        talker,
129        sentence_type,
130        fields,
131        tag_block,
132    })
133}
134
135/// Encode fields into a valid NMEA 0183 sentence string.
136///
137/// Computes XOR checksum and appends `*XX\r\n`.
138///
139/// # Examples
140///
141/// ```
142/// use nmea_kit::encode_frame;
143///
144/// let sentence = encode_frame('$', "WI", "MWD", &["270.0", "T", "268.5", "M", "12.4", "N", "6.4", "M"]);
145/// assert!(sentence.starts_with("$WIMWD,"));
146/// assert!(sentence.ends_with("\r\n"));
147/// ```
148pub fn encode_frame(prefix: char, talker: &str, sentence_type: &str, fields: &[&str]) -> String {
149    let body = if fields.is_empty() {
150        format!("{talker}{sentence_type}")
151    } else {
152        format!("{talker}{sentence_type},{}", fields.join(","))
153    };
154
155    let checksum = body.bytes().fold(0u8, |acc, b| acc ^ b);
156    format!("{prefix}{body}*{checksum:02X}\r\n")
157}
158
159/// Strip an optional IEC 61162-450 tag block from the beginning of the line.
160/// Returns `(Option<tag_block_content>, remaining_line)`.
161fn strip_tag_block(line: &str) -> Result<(Option<&str>, &str), FrameError> {
162    if let Some(rest) = line.strip_prefix('\\') {
163        match rest.find('\\') {
164            Some(close) => {
165                let tag = &rest[..close];
166                let remaining = &rest[close + 1..];
167                Ok((Some(tag), remaining))
168            }
169            None => Err(FrameError::MalformedTagBlock),
170        }
171    } else {
172        Ok((None, line))
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn ais_multi_fragment_signalk() {
182        let frame1 = parse_frame(
183            "!AIVDM,2,1,0,A,53brRt4000010SG700iE@LE8@Tp4000000000153P615t0Ht0SCkjH4jC1C,0*25",
184        )
185        .expect("AIS fragment 1");
186        assert_eq!(frame1.prefix, '!');
187        assert_eq!(frame1.sentence_type, "VDM");
188        assert_eq!(frame1.fields[1], "1"); // fragment number
189    }
190
191    #[test]
192    fn apb_fixture_signalk() {
193        let frame =
194            parse_frame("$GPAPB,A,A,0.10,R,N,V,V,011,M,DEST,011,M,011,M*3C").expect("valid APB");
195        assert_eq!(frame.sentence_type, "APB");
196        assert_eq!(frame.fields[9], "DEST");
197    }
198
199    #[test]
200    fn dbt_sounder_gpsd() {
201        let frame =
202            parse_frame("$SDDBT,7.7,f,2.3,M,1.3,F*05").expect("valid DBT from GPSD sounder.log");
203        assert_eq!(frame.sentence_type, "DBT");
204        assert_eq!(frame.fields[2], "2.3"); // meters
205    }
206
207    #[test]
208    fn dpt_fixtures_signalk() {
209        let fixtures = [
210            ("$IIDPT,4.1,0.0*45", "4.1", "0.0"),
211            ("$IIDPT,4.1,1.0*44", "4.1", "1.0"),
212            ("$IIDPT,4.1,-1.0*69", "4.1", "-1.0"),
213        ];
214        for (fix, depth, offset) in &fixtures {
215            let frame = parse_frame(fix).unwrap_or_else(|e| panic!("failed to parse {fix}: {e}"));
216            assert_eq!(frame.sentence_type, "DPT");
217            assert_eq!(frame.fields[0], *depth);
218            assert_eq!(frame.fields[1], *offset);
219        }
220    }
221
222    #[test]
223    fn dpt_humminbird_gpsd() {
224        let frame = parse_frame("$INDPT,2.2,0.0*47").expect("valid DPT from GPSD humminbird");
225        assert_eq!(frame.talker, "IN");
226        assert_eq!(frame.sentence_type, "DPT");
227    }
228
229    #[test]
230    fn encode_no_fields() {
231        let result = encode_frame('$', "GP", "RMC", &[]);
232        assert!(result.starts_with("$GPRMC*"));
233    }
234
235    #[test]
236    fn encode_simple_sentence() {
237        let result = encode_frame(
238            '$',
239            "WI",
240            "MWD",
241            &["270.0", "T", "268.5", "M", "12.4", "N", "6.4", "M"],
242        );
243        assert!(result.starts_with("$WIMWD,270.0,T,268.5,M,12.4,N,6.4,M*"));
244        assert!(result.ends_with("\r\n"));
245        // Verify checksum is valid by re-parsing
246        let frame = parse_frame(result.trim()).expect("encoded sentence should be parseable");
247        assert_eq!(frame.sentence_type, "MWD");
248    }
249
250    #[test]
251    fn encode_with_empty_fields() {
252        let result = encode_frame(
253            '$',
254            "GP",
255            "APB",
256            &["", "", "", "", "", "", "", "", "", "", "", "", "", ""],
257        );
258        let frame = parse_frame(result.trim()).expect("should re-parse");
259        assert_eq!(frame.sentence_type, "APB");
260        assert!(frame.fields.iter().all(|f| f.is_empty()));
261    }
262
263    #[test]
264    fn error_bad_checksum() {
265        assert!(matches!(
266            parse_frame("$GPRMC,175957.917,A*FF"),
267            Err(FrameError::BadChecksum { .. })
268        ));
269    }
270
271    #[test]
272    fn error_empty_input() {
273        assert_eq!(parse_frame(""), Err(FrameError::Empty));
274        assert_eq!(parse_frame("   "), Err(FrameError::Empty));
275    }
276
277    #[test]
278    fn error_invalid_prefix() {
279        assert!(matches!(
280            parse_frame("GPRMC,175957.917,A*00"),
281            Err(FrameError::InvalidPrefix('G'))
282        ));
283    }
284
285    #[test]
286    fn error_malformed_tag_block() {
287        assert_eq!(
288            parse_frame("\\s:FooBar$GPRMC,175957.917,A*00"),
289            Err(FrameError::MalformedTagBlock)
290        );
291    }
292
293    #[test]
294    fn error_too_short() {
295        assert_eq!(parse_frame("$GP*17"), Err(FrameError::TooShort));
296    }
297
298    #[test]
299    fn hdg_fixtures_signalk() {
300        let frame = parse_frame("$INHDG,180,5,W,10,W*6D").expect("valid HDG");
301        assert_eq!(frame.sentence_type, "HDG");
302        assert_eq!(frame.fields[0], "180");
303        assert_eq!(frame.fields[1], "5");
304        assert_eq!(frame.fields[2], "W");
305    }
306
307    #[test]
308    fn hdt_saab_gpsd() {
309        let frame = parse_frame("$HEHDT,4.0,T*2B").expect("valid HDT from GPSD saab-r4");
310        assert_eq!(frame.talker, "HE");
311        assert_eq!(frame.sentence_type, "HDT");
312    }
313
314    #[test]
315    fn mtw_humminbird_gpsd() {
316        let frame = parse_frame("$INMTW,17.9,C*1B").expect("valid MTW from GPSD humminbird");
317        assert_eq!(frame.sentence_type, "MTW");
318        assert_eq!(frame.fields[0], "17.9");
319    }
320
321    #[test]
322    fn mwd_fixtures_signalk() {
323        // From SignalK test suite
324        let fixtures = [
325            "$IIMWD,,,046.,M,10.1,N,05.2,M*0B",
326            "$IIMWD,046.,T,046.,M,10.1,N,,*17",
327            "$IIMWD,046.,T,,,,,5.2,M*72",
328        ];
329        for fix in &fixtures {
330            let frame = parse_frame(fix).unwrap_or_else(|e| panic!("failed to parse {fix}: {e}"));
331            assert_eq!(frame.sentence_type, "MWD");
332        }
333    }
334
335    #[test]
336    fn parse_ais_sentence() {
337        let frame =
338            parse_frame("!AIVDM,1,1,,A,13u@Dt002s000000000000000000,0*60").expect("valid frame");
339        assert_eq!(frame.prefix, '!');
340        assert_eq!(frame.talker, "AI");
341        assert_eq!(frame.sentence_type, "VDM");
342        assert_eq!(frame.fields[0], "1");
343    }
344
345    #[test]
346    fn parse_depth_sentence() {
347        let frame = parse_frame("$SDDBT,7.7,f,2.3,M,1.3,F*05").expect("valid frame");
348        assert_eq!(frame.talker, "SD");
349        assert_eq!(frame.sentence_type, "DBT");
350        assert_eq!(frame.fields[2], "2.3");
351    }
352
353    #[test]
354    fn parse_empty_fields() {
355        let frame = parse_frame("$GPAPB,,,,,,,,,,,,,,*44").expect("valid frame");
356        assert_eq!(frame.sentence_type, "APB");
357        assert!(frame.fields.iter().all(|f| f.is_empty()));
358    }
359
360    #[test]
361    fn parse_multi_constellation_talker() {
362        // GN = multi-constellation GNSS
363        let frame =
364            parse_frame("$GNRMC,175957.917,A,3857.1234,N,07705.1234,W,0.0,0.0,010100,,,A*69")
365                .expect("valid frame");
366        assert_eq!(frame.talker, "GN");
367        assert_eq!(frame.sentence_type, "RMC");
368    }
369
370    #[test]
371    fn parse_no_checksum_accepted() {
372        let result = parse_frame("$GPRMC,175957.917,A,3857.1234,N,07705.1234,W,0.0,0.0,010100,,,A");
373        assert!(result.is_ok());
374    }
375
376    #[test]
377    fn parse_standard_nmea_sentence() {
378        let frame =
379            parse_frame("$GPRMC,175957.917,A,3857.1234,N,07705.1234,W,0.0,0.0,010100,,,A*77")
380                .expect("valid frame");
381        assert_eq!(frame.prefix, '$');
382        assert_eq!(frame.talker, "GP");
383        assert_eq!(frame.sentence_type, "RMC");
384        assert_eq!(frame.fields[0], "175957.917");
385        assert_eq!(frame.fields[1], "A");
386        assert_eq!(frame.tag_block, None);
387    }
388
389    #[test]
390    fn parse_wind_sentence() {
391        let frame = parse_frame("$WIMWD,270.0,T,268.5,M,12.4,N,6.4,M*63").expect("valid frame");
392        assert_eq!(frame.talker, "WI");
393        assert_eq!(frame.sentence_type, "MWD");
394        assert_eq!(frame.fields.len(), 8);
395        assert_eq!(frame.fields[0], "270.0");
396        assert_eq!(frame.fields[1], "T");
397    }
398
399    #[test]
400    fn parse_with_tag_block() {
401        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");
402        assert!(frame.tag_block.is_some());
403        assert_eq!(frame.prefix, '$');
404        assert_eq!(frame.sentence_type, "RMC");
405    }
406
407    #[test]
408    fn rot_saab_gpsd() {
409        let frame = parse_frame("$HEROT,0.0,A*2B").expect("valid ROT from GPSD saab-r4");
410        assert_eq!(frame.sentence_type, "ROT");
411    }
412
413    #[test]
414    fn roundtrip_parse_encode_parse() {
415        let original = "$WIMWD,270.0,T,268.5,M,12.4,N,6.4,M*63";
416        let frame1 = parse_frame(original).expect("parse original");
417        let encoded = encode_frame(
418            frame1.prefix,
419            frame1.talker,
420            frame1.sentence_type,
421            &frame1.fields,
422        );
423        let frame2 = parse_frame(encoded.trim()).expect("parse re-encoded");
424        assert_eq!(frame1.talker, frame2.talker);
425        assert_eq!(frame1.sentence_type, frame2.sentence_type);
426        assert_eq!(frame1.fields, frame2.fields);
427    }
428}