From 210488e75ed5a2ce124388724fa35e280c1d44ef Mon Sep 17 00:00:00 2001 From: Ragnarok Date: Fri, 25 Sep 2026 09:56:46 +0300 Subject: [PATCH] fix(nmea0183, ais): read what real receivers send Checked against the gpsd regression logs and the Signal K samples. - Sentences up to 102 bytes are read (MAX_ACCEPTED_BYTES); encode still writes at most 82. VDM payloads up to 80 characters. - A DOP of 0, an empty variation and a variation of 999 or more read as not available instead of refusing the sentence. - AIS headings 360 to 510 read as not available, like 511; the rest of the report is kept. - OutOfRange no longer prints an unbounded limit as hundreds of digits. kinavis-kernel 1.0.1, kinavis-nmea0183 0.1.1, kinavis-ais 0.1.1. --- Cargo.lock | 6 +- Cargo.toml | 2 +- crates/kinavis-ais/Cargo.toml | 2 +- crates/kinavis-ais/src/fields.rs | 7 +- crates/kinavis-ais/tests/messages.rs | 24 ++++-- crates/kinavis-kernel/Cargo.toml | 2 +- crates/kinavis-kernel/src/error.rs | 58 ++++++++++++- crates/kinavis-nmea0183/Cargo.toml | 2 +- crates/kinavis-nmea0183/README.md | 2 + crates/kinavis-nmea0183/src/error.rs | 6 +- crates/kinavis-nmea0183/src/field.rs | 20 ++++- crates/kinavis-nmea0183/src/frame.rs | 32 ++++--- crates/kinavis-nmea0183/src/lib.rs | 12 ++- crates/kinavis-nmea0183/src/sentence/vdm.rs | 7 +- crates/kinavis-nmea0183/tests/sentences.rs | 93 ++++++++++++++++++++- 15 files changed, 230 insertions(+), 45 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b34e773..2f19870 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,7 +51,7 @@ dependencies = [ [[package]] name = "kinavis-ais" -version = "0.1.0" +version = "0.1.1" dependencies = [ "kinavis-kernel", "kinavis-nmea0183", @@ -93,7 +93,7 @@ dependencies = [ [[package]] name = "kinavis-kernel" -version = "1.0.0" +version = "1.0.1" dependencies = [ "libm", "proptest", @@ -103,7 +103,7 @@ dependencies = [ [[package]] name = "kinavis-nmea0183" -version = "0.1.0" +version = "0.1.1" dependencies = [ "kinavis-kernel", "proptest", diff --git a/Cargo.toml b/Cargo.toml index f220ed5..eaa6b64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ license = "MIT OR Apache-2.0" # Workspace crates, so members reference siblings with the workspace version. kinavis-kernel = { version = "1.0.0", path = "crates/kinavis-kernel", default-features = false } kinavis = { version = "1.0.0", path = "crates/kinavis", default-features = false } -kinavis-nmea0183 = { version = "0.1.0", path = "crates/kinavis-nmea0183", default-features = false } +kinavis-nmea0183 = { version = "0.1.1", path = "crates/kinavis-nmea0183", default-features = false } libm = { version = "0.2", default-features = false } serde = { version = "1", default-features = false, features = [ diff --git a/crates/kinavis-ais/Cargo.toml b/crates/kinavis-ais/Cargo.toml index 3c66113..cbe53a2 100644 --- a/crates/kinavis-ais/Cargo.toml +++ b/crates/kinavis-ais/Cargo.toml @@ -6,7 +6,7 @@ readme = "README.md" keywords = ["ais", "aivdm", "nmea", "marine", "no-std"] categories = ["parser-implementations", "science", "no-std::no-alloc"] # Satellite crate, versioned independently of the kernel. -version = "0.1.0" +version = "0.1.1" authors.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/kinavis-ais/src/fields.rs b/crates/kinavis-ais/src/fields.rs index ffdde4c..ede3bdc 100644 --- a/crates/kinavis-ais/src/fields.rs +++ b/crates/kinavis-ais/src/fields.rs @@ -14,7 +14,8 @@ const NO_SPEED: u32 = 1023; const NO_LONGITUDE: i32 = 181 * 600_000; const NO_LATITUDE: i32 = 91 * 600_000; const NO_COURSE: u32 = 3600; -const NO_HEADING: u32 = 511; +/// 511, and the unused 360 to 510. +const NOT_A_HEADING: u32 = 360; /// Message of at least `needed` bits, so fields are read without per-field /// length checks. @@ -111,8 +112,10 @@ pub(crate) fn course(field: u32) -> Result, AisError> { .map_err(value_of("course")) } +/// 360 to 510 are unused by the standard but sent by transponders; they read +/// as not available, like 511, so the rest of the report is kept. pub(crate) fn heading(field: u32) -> Result, AisError> { - if field == NO_HEADING { + if field >= NOT_A_HEADING { return Ok(None); } TrueCourse::new(f64::from(field)) diff --git a/crates/kinavis-ais/tests/messages.rs b/crates/kinavis-ais/tests/messages.rs index 901f4d9..63abbbc 100644 --- a/crates/kinavis-ais/tests/messages.rs +++ b/crates/kinavis-ais/tests/messages.rs @@ -171,14 +171,9 @@ fn a_value_the_domain_rejects_is_an_error_not_a_position() { error: KernelError::OutOfRange { .. } }) )); - // Heading 400°. - assert!(matches!( - report("!AIVDM,1,1,,A,13aEOK000000000000000(&json).unwrap(), message); } + +#[test] +fn a_heading_the_standard_does_not_use_reads_as_not_available() { + // Class B reports from the gpsd regression logs `test/daemon/ais-*.log` + // (copyright the GPSD project, BSD-2-Clause). Heading 480: the position, + // course and speed are kept. + let transponder = report("!AIVDM,1,1,,B,B6:JE@P002;4TD5HfhH03h24SP00,0*0C").unwrap(); + assert_eq!(transponder.heading, None); + assert!(transponder.position.is_some()); + // 360, the first unused value, likewise. + let unused = report("!AIVDM,1,1,,A,B3KILP000@LWW8`VVWcQ2l063P06,0*75").unwrap(); + assert_eq!(unused.heading, None); +} diff --git a/crates/kinavis-kernel/Cargo.toml b/crates/kinavis-kernel/Cargo.toml index b95256a..84144b2 100644 --- a/crates/kinavis-kernel/Cargo.toml +++ b/crates/kinavis-kernel/Cargo.toml @@ -5,7 +5,7 @@ documentation = "https://docs.rs/kinavis-kernel" readme = "README.md" keywords = ["navigation", "marine", "geodesy", "wgs84", "no-std"] categories = ["science::geo", "no-std::no-alloc", "data-structures"] -version.workspace = true +version = "1.0.1" authors.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/kinavis-kernel/src/error.rs b/crates/kinavis-kernel/src/error.rs index 82231e6..7344c98 100644 --- a/crates/kinavis-kernel/src/error.rs +++ b/crates/kinavis-kernel/src/error.rs @@ -207,6 +207,23 @@ pub fn ensure_range(parameter: &'static str, value: f64, min: f64, max: f64) -> Ok(()) } +/// Allowed interval in words. `f64::MIN_POSITIVE` as the minimum reads as +/// "greater than 0", and `f64::MIN` or `f64::MAX` as unbounded, so no bound is +/// printed as hundreds of digits. +fn write_interval(f: &mut fmt::Formatter<'_>, min: f64, max: f64) -> fmt::Result { + let positive = min > 0.0 && min <= f64::MIN_POSITIVE; + let below = min > f64::MIN; + let above = max < f64::MAX; + match (positive, below, above) { + (true, _, true) => write!(f, "greater than 0 and at most {max}"), + (true, _, false) => f.write_str("greater than 0"), + (false, true, true) => write!(f, "between {min} and {max}"), + (false, true, false) => write!(f, "at least {min}"), + (false, false, true) => write!(f, "at most {max}"), + (false, false, false) => f.write_str("finite"), + } +} + impl fmt::Display for KernelError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -218,10 +235,10 @@ impl fmt::Display for KernelError { value, min, max, - } => write!( - f, - "{parameter} out of range: {value}. Must be between {min} and {max}" - ), + } => { + write!(f, "{parameter} out of range: {value}. Must be ")?; + write_interval(f, *min, *max) + } Self::InsufficientData { found, required, @@ -373,6 +390,39 @@ mod tests { ); } + #[test] + fn an_unbounded_side_is_not_printed_as_a_number() { + let message = |min, max| { + KernelError::OutOfRange { + parameter: "x", + value: 0.0, + min, + max, + } + .to_string() + }; + assert_eq!( + message(0.0, 1.0), + "x out of range: 0. Must be between 0 and 1" + ); + assert_eq!( + message(f64::MIN_POSITIVE, f64::MAX), + "x out of range: 0. Must be greater than 0" + ); + assert_eq!( + message(f64::MIN_POSITIVE, 1e6), + "x out of range: 0. Must be greater than 0 and at most 1000000" + ); + assert_eq!( + message(1.0, f64::MAX), + "x out of range: 0. Must be at least 1" + ); + assert_eq!( + message(f64::MIN, -1.0), + "x out of range: 0. Must be at most -1" + ); + } + #[test] fn the_checks_report_what_they_reject() { assert!(ensure_finite("x", 1.0).is_ok()); diff --git a/crates/kinavis-nmea0183/Cargo.toml b/crates/kinavis-nmea0183/Cargo.toml index 686c145..90161a5 100644 --- a/crates/kinavis-nmea0183/Cargo.toml +++ b/crates/kinavis-nmea0183/Cargo.toml @@ -6,7 +6,7 @@ readme = "README.md" keywords = ["nmea", "nmea0183", "gnss", "gps", "no-std"] categories = ["parser-implementations", "science", "no-std::no-alloc"] # Satellite crate, versioned independently of the kernel. -version = "0.1.0" +version = "0.1.1" authors.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/kinavis-nmea0183/README.md b/crates/kinavis-nmea0183/README.md index 6f2a261..862c023 100644 --- a/crates/kinavis-nmea0183/README.md +++ b/crates/kinavis-nmea0183/README.md @@ -12,6 +12,8 @@ translated into a kernel `GnssFix`. The sentence never reaches the domain. `Sentence::Unsupported` with a verified checksum. - Plausibility bounds on numeric fields (speed, altitude, DOP, differential age): a value outside is rejected as a corrupt field. +- Sentences up to 102 bytes are read, past the standard's 82: high-precision + GNSS and AIS receivers write longer ones. - Encoding as well as parsing, for generation and round-trip tests; a sentence longer than the standard's 82 bytes is refused, never written. - No allocation: parses `&[u8]` in place. diff --git a/crates/kinavis-nmea0183/src/error.rs b/crates/kinavis-nmea0183/src/error.rs index 299f842..1ec53e5 100644 --- a/crates/kinavis-nmea0183/src/error.rs +++ b/crates/kinavis-nmea0183/src/error.rs @@ -10,7 +10,11 @@ use kinavis_kernel::KernelError; #[non_exhaustive] #[derive(Debug, Clone, PartialEq)] pub enum NmeaError { - /// Sentence, read or to be written, longer than the standard allows. + /// Sentence longer than the limit: [`MAX_ACCEPTED_BYTES`] to read, + /// [`MAX_SENTENCE_BYTES`] to write. + /// + /// [`MAX_ACCEPTED_BYTES`]: crate::MAX_ACCEPTED_BYTES + /// [`MAX_SENTENCE_BYTES`]: crate::MAX_SENTENCE_BYTES TooLong { /// Sentence length. length: usize, diff --git a/crates/kinavis-nmea0183/src/field.rs b/crates/kinavis-nmea0183/src/field.rs index 28bd8ec..160f50f 100644 --- a/crates/kinavis-nmea0183/src/field.rs +++ b/crates/kinavis-nmea0183/src/field.rs @@ -33,6 +33,9 @@ pub(crate) mod bounds { pub(crate) const DOP: (f64, f64) = (0.0, 100.0); /// Age of the differential correction, seconds. pub(crate) const DIFFERENTIAL_AGE_SECONDS: (f64, f64) = (0.0, 9999.0); + /// Magnetic variation from which the field reads as not available: + /// receivers without a magnetic model write 999.9. + pub(crate) const NO_VARIATION_DEGREES: f64 = 999.0; } /// Field with its index, for error reporting. @@ -259,7 +262,9 @@ impl<'a> Field<'a> { &self, east_west: Field<'_>, ) -> Result, NmeaError> { - if self.is_empty() && east_west.is_empty() { + // No magnitude, whatever the direction field holds: some receivers + // write the direction alone, or drop its field and shift the mode in. + if self.is_empty() { return Ok(None); } let magnitude = self.decimal("variation")?; @@ -268,6 +273,9 @@ impl<'a> Field<'a> { b'W' => -1.0, _ => return Err(east_west.bad("variation direction")), }; + if magnitude >= bounds::NO_VARIATION_DEGREES { + return Ok(None); + } Variation::new(magnitude * sign) .map(Some) .map_err(|e| self.value(e)) @@ -287,13 +295,17 @@ impl<'a> Field<'a> { .map_err(|e| self.value(e)) } + /// Zero is read as not available: receivers without a fix write `0.0`. pub(crate) fn optional_dop(&self) -> Result, NmeaError> { if self.is_empty() { return Ok(None); } - Dop::new(self.bounded_decimal("dilution of precision", bounds::DOP)?) - .map(Some) - .map_err(|e| self.value(e)) + let value = self.bounded_decimal("dilution of precision", bounds::DOP)?; + // Bounded below by zero, so this is exactly zero. + if value <= 0.0 { + return Ok(None); + } + Dop::new(value).map(Some).map_err(|e| self.value(e)) } pub(crate) fn optional_unsigned( diff --git a/crates/kinavis-nmea0183/src/frame.rs b/crates/kinavis-nmea0183/src/frame.rs index 8ab1120..a704b18 100644 --- a/crates/kinavis-nmea0183/src/frame.rs +++ b/crates/kinavis-nmea0183/src/frame.rs @@ -1,15 +1,25 @@ //! Sentence framing: start character, address, fields, checksum. //! -//! Checks what every sentence must satisfy — at most 82 bytes, printable ASCII, -//! `$` or `!` start, matching `*hh` checksum — and returns the address and -//! fields as slices of the input. No copying. +//! Checks what every sentence must satisfy — at most [`MAX_ACCEPTED_BYTES`], +//! printable ASCII, `$` or `!` start, matching `*hh` checksum — and returns the +//! address and fields as slices of the input. No copying. use crate::error::NmeaError; use crate::field::Field; -/// Maximum sentence length, `$` and `CR LF` included. +/// Maximum sentence length in the standard, `$` and `CR LF` included. +/// [`encode`](crate::encode) never writes more. pub const MAX_SENTENCE_BYTES: usize = 82; +/// Maximum sentence length [`parse`](crate::parse) accepts, `$` and `CR LF` +/// included. +/// +/// Above the standard's 82 bytes: high-precision receivers write GGA and RMC +/// with seven or eight decimals of a minute, and AIS receivers write fragments +/// with up to 64 payload characters, both past 82 bytes. The same limit as +/// gpsd. +pub const MAX_ACCEPTED_BYTES: usize = 102; + /// Sentence that passed framing checks. #[derive(Debug, Clone, Copy)] pub(crate) struct Frame<'a> { @@ -24,10 +34,10 @@ pub(crate) struct Frame<'a> { impl<'a> Frame<'a> { /// Validates framing and splits the sentence. pub(crate) fn parse(sentence: &'a [u8]) -> Result { - if sentence.len() > MAX_SENTENCE_BYTES { + if sentence.len() > MAX_ACCEPTED_BYTES { return Err(NmeaError::TooLong { length: sentence.len(), - limit: MAX_SENTENCE_BYTES, + limit: MAX_ACCEPTED_BYTES, }); } let trimmed = trim_terminator(sentence); @@ -53,7 +63,7 @@ impl<'a> Frame<'a> { } // Offsets within the sentence: +1 for the start character. - for (offset, &byte) in (1..=MAX_SENTENCE_BYTES).zip(body) { + for (offset, &byte) in (1..=MAX_ACCEPTED_BYTES).zip(body) { if !is_allowed(byte) { return Err(NmeaError::BadCharacter { offset }); } @@ -150,12 +160,12 @@ impl<'a> Fields<'a> { /// Fields with their indices. A sentence has fewer fields than bytes, so /// the bounded range is never exhausted first. pub(crate) fn indexed(self) -> impl Iterator> { - self.zip(0..MAX_SENTENCE_BYTES) + self.zip(0..MAX_ACCEPTED_BYTES) .map(|(bytes, index)| Field { bytes, index }) } } -/// Occurrences of a byte. Sentences are ≤ 82 bytes, so a naive count suffices. +/// Occurrences of a byte. Sentences are ≤ 102 bytes, so a naive count suffices. /// /// A fold rather than `count`, whose overflow-checked counter the strict /// profile would flag. @@ -225,8 +235,8 @@ mod tests { NmeaError::BadChecksum { .. } )); assert!(matches!( - Frame::parse(&[b'$'; 83]).unwrap_err(), - NmeaError::TooLong { length: 83, .. } + Frame::parse(&[b'$'; 103]).unwrap_err(), + NmeaError::TooLong { length: 103, .. } )); // Control character inside an otherwise valid sentence. let mut bad = *b"$GPGLL,\x01*00"; diff --git a/crates/kinavis-nmea0183/src/lib.rs b/crates/kinavis-nmea0183/src/lib.rs index 6c76c40..d0064b9 100644 --- a/crates/kinavis-nmea0183/src/lib.rs +++ b/crates/kinavis-nmea0183/src/lib.rs @@ -53,12 +53,16 @@ //! | Speed over ground | 0 to 1000 kn; 0 to 1852 km/h | //! | Altitude (GGA) | −10 000 m to 100 000 m | //! | Geoid separation (GGA) | −1000 m to 1000 m | -//! | Dilution of precision | above 0, at most 100 | +//! | Dilution of precision | 0 to 100; 0 reads as not available | +//! | Magnetic variation (RMC) | 180° either way; 999 and above read as not available | //! | Differential age (GGA) | 0 s to 9999 s | //! +//! [`parse`] accepts sentences up to [`MAX_ACCEPTED_BYTES`], past the +//! standard's [`MAX_SENTENCE_BYTES`]: receivers write longer ones. //! [`encode`] refuses a sentence longer than [`MAX_SENTENCE_BYTES`] with -//! [`NmeaError::TooLong`]. RMC, GLL, VTG and VDM always fit; a GGA can exceed -//! the limit only with several fields near their bounds at once. +//! [`NmeaError::TooLong`]. RMC, GLL and VTG always fit; a VDM fits with at +//! most 62 payload characters; a GGA can exceed the limit only with several +//! fields near their bounds at once. //! //! # Not supported //! @@ -81,7 +85,7 @@ mod sentence; pub use encode::encode; pub use error::{NmeaError, TranslationError}; -pub use frame::MAX_SENTENCE_BYTES; +pub use frame::{MAX_ACCEPTED_BYTES, MAX_SENTENCE_BYTES}; pub use sentence::{ Address, Channel, Date, Gga, Gll, Mode, Payload, Rmc, Sentence, Status, Talker, TimeOfDay, Vdm, Vtg, MAX_FRAGMENTS, MAX_PAYLOAD_CHARS, diff --git a/crates/kinavis-nmea0183/src/sentence/vdm.rs b/crates/kinavis-nmea0183/src/sentence/vdm.rs index 73897f7..97ee276 100644 --- a/crates/kinavis-nmea0183/src/sentence/vdm.rs +++ b/crates/kinavis-nmea0183/src/sentence/vdm.rs @@ -19,9 +19,10 @@ use crate::sentence::Talker; /// Maximum payload characters per fragment. /// -/// 82 bytes minus address, five short fields, commas, checksum and terminator -/// leaves 62; receivers send at most that. -pub const MAX_PAYLOAD_CHARS: usize = 62; +/// [`MAX_ACCEPTED_BYTES`](crate::MAX_ACCEPTED_BYTES) minus address, five +/// short fields, commas, checksum and terminator. The standard's 82 bytes +/// leave 62; receivers send up to 64. +pub const MAX_PAYLOAD_CHARS: usize = 80; /// Maximum number of fragments per message. pub const MAX_FRAGMENTS: u8 = 9; diff --git a/crates/kinavis-nmea0183/tests/sentences.rs b/crates/kinavis-nmea0183/tests/sentences.rs index 3b040bf..476e3af 100644 --- a/crates/kinavis-nmea0183/tests/sentences.rs +++ b/crates/kinavis-nmea0183/tests/sentences.rs @@ -14,7 +14,7 @@ use kinavis_kernel::GnssFix; use kinavis_kernel::KernelError; use kinavis_nmea0183::{ encode, parse, Channel, Date, Mode, NmeaError, Sentence, Status, Talker, TimeOfDay, - TranslationError, MAX_PAYLOAD_CHARS, MAX_SENTENCE_BYTES, + TranslationError, MAX_ACCEPTED_BYTES, MAX_PAYLOAD_CHARS, MAX_SENTENCE_BYTES, }; #[test] @@ -416,3 +416,94 @@ fn a_sentence_that_would_exceed_the_standard_length_is_refused_by_encode() { }) )); } + +// Sentences below are from the gpsd regression logs (`test/daemon`, copyright +// the GPSD project, BSD-2-Clause), recorded from the receivers named. + +#[test] +fn a_high_precision_receiver_is_read_past_the_standard_length() { + // u-blox ZED-F9P with high-precision NMEA: 89 bytes. + let line = "$GNGGA,014500.00,4404.1306024,N,12118.8446777,W,2,12,0.49,1129.913,M,-21.350,M,,0278*4C\r\n"; + assert!(line.len() > MAX_SENTENCE_BYTES); + let Sentence::Gga(gga) = parse(line.as_bytes()).unwrap() else { + panic!("not GGA") + }; + let position = gga.position.unwrap(); + assert_eq!( + format!("{:.7}", position.latitude().degrees()), + "44.0688434" + ); + assert_eq!( + format!("{:.7}", position.longitude().degrees()), + "-121.3140780" + ); + assert_eq!(gga.hdop.unwrap().value(), 0.49); +} + +#[test] +fn a_dop_of_zero_before_the_first_fix_reads_as_not_available() { + // SkyTraq S2525F8-BD-RTK after a cold start: 84 bytes, HDOP `0.0`. + let line = "$GPGGA,212406.000,0000.0000000,N,00000.0000000,E,0,00,0.0,0.000,M,0.000,M,,0000*6E"; + let Sentence::Gga(gga) = parse(line.as_bytes()).unwrap() else { + panic!("not GGA") + }; + assert_eq!(gga.hdop, None); + assert_eq!(gga.fix_type, FixType::None); +} + +#[test] +fn an_ais_fragment_with_a_64_character_payload_is_read() { + // AIS receiver: the first of two fragments of a type 5 message, 86 bytes. + let line = + "!AIVDM,2,1,0,A,56K2=I02ADS``C;W3J0PuE8Tr0pvs>222222221@6`8376630:2T81SP`3iQ`888,0*51"; + let Sentence::Vdm(vdm) = parse(line.as_bytes()).unwrap() else { + panic!("not VDM") + }; + assert_eq!(vdm.payload.as_bytes().len(), 64); +} + +#[test] +fn a_sentence_past_the_accepted_length_is_refused() { + let body = format!("GPGGA,{}0", "0,".repeat(46)); + let line = with_checksum(&body); + assert_eq!(line.len(), MAX_ACCEPTED_BYTES + 1); + assert_eq!( + parse(line.as_bytes()), + Err(NmeaError::TooLong { + length: MAX_ACCEPTED_BYTES + 1, + limit: MAX_ACCEPTED_BYTES, + }) + ); +} + +#[test] +fn a_variation_of_999_9_reads_as_not_available() { + // Caterpillar MS352 without a magnetic model: 93 bytes, variation `999.9`. + let line = + "$GPRMC,113938.50,A,3842.86006889,N,11705.43645510,W,0.011,46.405,231122,999.9000,E,D*2E"; + let Sentence::Rmc(rmc) = parse(line.as_bytes()).unwrap() else { + panic!("not RMC") + }; + assert_eq!(rmc.variation, None); + assert!(GnssFix::try_from(rmc).is_ok()); + // Between the physical limit and the marker: a corrupt field. + assert!(matches!( + parse( + with_checksum("GPRMC,113938.50,A,3842.86,N,11705.43,W,0.0,46.4,231122,500.0,E,D") + .as_bytes() + ), + Err(NmeaError::Value { .. }) + )); +} + +#[test] +fn a_variation_without_a_magnitude_reads_as_not_available() { + // Saab R4 AIS transponder: no variation direction field, so the mode + // indicator sits where the direction belongs. + let line = "$GPRMC,130711.00,A,5012.790800,N,00806.879600,W,0.5,3.0,010611,,A*6A"; + let Sentence::Rmc(rmc) = parse(line.as_bytes()).unwrap() else { + panic!("not RMC") + }; + assert_eq!(rmc.variation, None); + assert!(GnssFix::try_from(rmc).is_ok()); +}