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