Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
2 changes: 1 addition & 1 deletion crates/kinavis-ais/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions crates/kinavis-ais/src/fields.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -111,8 +112,10 @@ pub(crate) fn course(field: u32) -> Result<Option<TrueCourse>, 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<Option<TrueCourse>, AisError> {
if field == NO_HEADING {
if field >= NOT_A_HEADING {
return Ok(None);
}
TrueCourse::new(f64::from(field))
Expand Down
24 changes: 16 additions & 8 deletions crates/kinavis-ais/tests/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<P00000,0*68"),
Err(AisError::Value {
field: "heading",
error: KernelError::OutOfRange { .. }
})
));
// Heading 400° is unused by the standard, not corrupt: not available.
let report = report("!AIVDM,1,1,,A,13aEOK000000000000000<P00000,0*68").unwrap();
assert_eq!(report.heading, None);
}

#[test]
Expand Down Expand Up @@ -243,3 +238,16 @@ fn a_message_round_trips_through_serde() {
let json = serde_json::to_string(&message).unwrap();
assert_eq!(serde_json::from_str::<Message>(&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);
}
2 changes: 1 addition & 1 deletion crates/kinavis-kernel/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 54 additions & 4 deletions crates/kinavis-kernel/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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());
Expand Down
2 changes: 1 addition & 1 deletion crates/kinavis-nmea0183/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions crates/kinavis-nmea0183/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion crates/kinavis-nmea0183/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 16 additions & 4 deletions crates/kinavis-nmea0183/src/field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -259,7 +262,9 @@ impl<'a> Field<'a> {
&self,
east_west: Field<'_>,
) -> Result<Option<Variation>, 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")?;
Expand All @@ -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))
Expand All @@ -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<Option<Dop>, 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<T: FromStr>(
Expand Down
32 changes: 21 additions & 11 deletions crates/kinavis-nmea0183/src/frame.rs
Original file line number Diff line number Diff line change
@@ -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> {
Expand All @@ -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<Self, NmeaError> {
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);
Expand All @@ -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 });
}
Expand Down Expand Up @@ -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<Item = Field<'a>> {
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.
Expand Down Expand Up @@ -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";
Expand Down
12 changes: 8 additions & 4 deletions crates/kinavis-nmea0183/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
//!
Expand All @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions crates/kinavis-nmea0183/src/sentence/vdm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading