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> {
57 let line = line.trim();
58 if line.is_empty() {
59 return Err(FrameError::Empty);
60 }
61
62 let (tag_block, line) = strip_tag_block(line)?;
64
65 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 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 if let Some(cs_str) = checksum_str {
85 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 if body.len() < 5 {
99 return Err(FrameError::TooShort);
100 }
101
102 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 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(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
175fn 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"); }
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"); }
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 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 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 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 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 let f = parse_frame("$GPABC,*7B").expect("valid");
434 assert_eq!(f.fields, vec![""]);
435 }
436
437 #[test]
438 fn checksum_format_strictness() {
439 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 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}