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 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 if body.len() < 5 {
94 return Err(FrameError::TooShort);
95 }
96
97 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 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 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
135pub 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
159fn 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"); }
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"); }
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 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 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 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}