navcore_signalk/delta.rs
1//! Reading a Signal K delta, in the units the rest of this workspace uses.
2//!
3//! The wire is SI and the rest of this workspace is not: Signal K carries
4//! courses and headings in **radians** and speeds in **metres per
5//! second**, while everything above `nav-math` works in degrees and
6//! knots. Position is the exception -- it is already degrees. That
7//! conversion happens here, once, rather than in every consumer, and it
8//! is the main thing this module is for.
9//!
10//! Anything unrecognised is skipped rather than refused. A Signal K server
11//! sends dozens of paths a caller may have no use for and will send more
12//! after the next update; a reader that failed on the first unknown one
13//! would stop working the day the boat gained a sensor.
14
15use nav_math::Position;
16
17/// Radians to degrees, and metres per second to knots. Written out because
18/// getting either backwards produces numbers that look plausible on screen.
19pub(crate) const KNOTS_PER_METRE_PER_SECOND: f64 = 3600.0 / nav_math::METRES_PER_NM;
20
21/// Whose data a delta is about.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum Context {
24 /// This vessel.
25 SelfVessel,
26 /// Somebody else -- an AIS target, or another boat the server knows
27 /// about. Kept rather than dropped so the caller can decide; feeding
28 /// another vessel's position into own-ship display is the kind of
29 /// mistake that only shows up at sea.
30 Other(String),
31}
32
33/// One value from one source.
34#[derive(Debug, Clone, PartialEq)]
35pub struct Reading {
36 /// The Signal K source label, `$source` or `source.label`. This is what
37 /// tells the boat's own GPS apart from the cockpit's, so it is not
38 /// optional as far as this crate is concerned -- a reading whose origin
39 /// is unknown gets the label `"unknown"` rather than being dropped.
40 pub source: String,
41 /// The sender's own timestamp, verbatim and unparsed.
42 ///
43 /// Deliberately not turned into an instant to compute age from. Age is
44 /// measured against the receiving clock (see [`fix::Sources`]),
45 /// because two computers on a boat disagree about the time far more
46 /// often than anyone expects, and an age computed across that
47 /// disagreement can come out negative.
48 pub timestamp: Option<String>,
49 /// What was measured.
50 pub measurement: Measurement,
51}
52
53/// What a sounding is measured from.
54///
55/// An echo sounder measures from its own face and knows nothing else. Every
56/// other reference is that measurement plus a distance somebody stated about
57/// the vessel, and the difference between them is not decoration: a keel
58/// offset is typically 0.3 to 1.5 m, which is the whole margin a boat has
59/// when it matters.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum Sounding {
62 /// From the transducer face. What the instrument actually measured, and
63 /// what NMEA's `DBT` carries with no offset at all.
64 BelowTransducer,
65 /// From the bottom of the keel. What a mariner means by "how much water
66 /// is under me".
67 BelowKeel,
68 /// From the water surface. What a chart means by a depth.
69 BelowSurface,
70}
71
72/// A sounding, and what it was measured from.
73///
74/// The reference travels with the number so that nothing downstream has to
75/// assume one. This is the same discipline a Raymarine, B&G or Garmin
76/// display keeps: the offset is stated once, the value is labelled, and an
77/// unconfigured installation reads out depth below the transducer rather
78/// than quietly claiming to be depth below the keel.
79#[derive(Debug, Clone, Copy, PartialEq)]
80pub struct Depth {
81 /// Metres. Signal K is already metric here, so nothing is converted.
82 pub metres: f64,
83 /// What it is measured from.
84 pub reference: Sounding,
85}
86
87/// The vessel's sounder geometry, as far as it is known.
88///
89/// Both are distances Signal K states positive downwards, and both are
90/// properties of the installation rather than of the water. They arrive on
91/// the wire like anything else when the server has been told them, and stay
92/// `None` when it has not.
93#[derive(Debug, Clone, Copy, Default, PartialEq)]
94pub struct Offsets {
95 /// Transducer face down to the bottom of the keel.
96 pub transducer_to_keel: Option<f64>,
97 /// Water surface down to the transducer face.
98 pub surface_to_transducer: Option<f64>,
99}
100
101impl Depth {
102 /// The same sounding measured from somewhere else, if the geometry to
103 /// get there is known.
104 ///
105 /// `None` when it is not, and that is the whole point of the type.
106 /// Guessing an offset -- or worse, leaving it at zero and calling the
107 /// result depth below the keel -- reports **more water than there is**,
108 /// by exactly the amount nobody stated. A depth display that does that
109 /// is wrong in the one direction it must never be wrong in.
110 ///
111 /// A negative result is not an error and is not clamped: it means the
112 /// keel is already in the bottom, which is worth showing.
113 #[must_use]
114 pub fn to(self, wanted: Sounding, offsets: Offsets) -> Option<Self> {
115 // Everything goes via the transducer, because that is the only
116 // reference the instrument actually measured.
117 let below_transducer = match self.reference {
118 Sounding::BelowTransducer => self.metres,
119 Sounding::BelowKeel => self.metres + offsets.transducer_to_keel?,
120 Sounding::BelowSurface => self.metres - offsets.surface_to_transducer?,
121 };
122 let metres = match wanted {
123 Sounding::BelowTransducer => below_transducer,
124 Sounding::BelowKeel => below_transducer - offsets.transducer_to_keel?,
125 Sounding::BelowSurface => below_transducer + offsets.surface_to_transducer?,
126 };
127 Some(Self {
128 metres,
129 reference: wanted,
130 })
131 }
132}
133
134/// The tracking status a target-tracking plugin publishes per target on
135/// `sensors.ais.status` -- confirmed live against `sk-ais-status-plugin`
136/// (not a Signal K specification path, a third-party convention). Its
137/// own README recommends the following treatment:
138///
139/// - `Unconfirmed`: shown, but de-emphasised -- not yet enough reports
140/// to trust as more than a suspect decode.
141/// - `Confirmed`: shown normally.
142/// - `Lost`: shown faded, with its age -- gone quiet longer than its
143/// own class's own patience allows.
144/// - `Remove`: dropped outright, the same as this crate's own fallback
145/// timeout for a server with no such plugin.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum AisTargetStatus {
148 /// A position has arrived, but not yet enough reports to trust it.
149 Unconfirmed,
150 /// Enough consistent reports have arrived to treat this normally.
151 Confirmed,
152 /// Gone quiet longer than its own class's own patience allows.
153 Lost,
154 /// Gone quiet long enough a display should drop it outright.
155 Remove,
156}
157
158impl AisTargetStatus {
159 /// The variant a wire string names, or `None` for anything this
160 /// crate does not recognise -- skipped, not refused, the same
161 /// "unknown paths and values are simply not readings" discipline
162 /// this module's own doc states for everything else.
163 fn from_wire(value: &str) -> Option<Self> {
164 match value {
165 "unconfirmed" => Some(Self::Unconfirmed),
166 "confirmed" => Some(Self::Confirmed),
167 "lost" => Some(Self::Lost),
168 "remove" => Some(Self::Remove),
169 _ => None,
170 }
171 }
172}
173
174/// Which kind of AIS transponder a target carries, `sensors.ais.class`.
175///
176/// Worth showing because it says what to expect of the rest: a Class B
177/// unit reports less often, and sends no heading, destination or draught
178/// worth reading.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum AisClass {
181 /// Class A: ships obliged to carry AIS.
182 A,
183 /// Class B: the lighter unit most pleasure craft carry.
184 B,
185}
186
187impl AisClass {
188 fn from_wire(value: &str) -> Option<Self> {
189 match value {
190 "A" => Some(Self::A),
191 "B" => Some(Self::B),
192 _ => None,
193 }
194 }
195}
196
197/// A Signal K notification's own severity -- the schema's `alarmState`
198/// enum, read off a notification's `state` field. Five of its six defined
199/// values are modelled here (`nominal` is left out: the schema defines it,
200/// but no producer this crate has actually been read against -- neither
201/// `signalk-derived-data` nor `signalk-ais-target-prioritizer`'s own CPA
202/// alarms, both confirmed live -- ever emits it, and the same
203/// skip-unrecognised discipline this module keeps everywhere else means
204/// an unmodelled value is simply not a reading rather than a parse
205/// failure).
206///
207/// `Ord`ered least to most severe, the same order the schema itself lists
208/// them in, so a caller can ask "at least `Alarm`" with a plain
209/// comparison rather than a match.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
211pub enum NotificationSeverity {
212 /// Nothing wrong.
213 Normal,
214 /// Worth a mariner's attention eventually.
215 Alert,
216 /// Worth a mariner's attention soon.
217 Warn,
218 /// Worth a mariner's attention now.
219 Alarm,
220 /// The most severe level the schema defines.
221 Emergency,
222}
223
224impl NotificationSeverity {
225 /// The variant a wire string names, or `None` for anything this
226 /// crate does not recognise.
227 fn from_wire(value: &str) -> Option<Self> {
228 match value {
229 "normal" => Some(Self::Normal),
230 "alert" => Some(Self::Alert),
231 "warn" => Some(Self::Warn),
232 "alarm" => Some(Self::Alarm),
233 "emergency" => Some(Self::Emergency),
234 _ => None,
235 }
236 }
237}
238
239/// The values a caller has a use for. Everything else on the wire is
240/// skipped.
241///
242/// `Clone`, not `Copy`: most variants are a plain number or [`Position`],
243/// cheap to duplicate either way, but
244/// [`CourseActiveRouteHref`](Measurement::CourseActiveRouteHref) carries
245/// an owned `String`, which `Copy` cannot hold -- a caller matching the
246/// same reading against several consumers in turn clones it explicitly.
247#[derive(Debug, Clone, PartialEq)]
248pub enum Measurement {
249 /// Where the vessel is.
250 Position(Position),
251 /// Course over ground, degrees true -- converted from Signal K's
252 /// radians.
253 CourseOverGround(f64),
254 /// Speed over ground in knots, converted from metres per second.
255 SpeedOverGround(f64),
256 /// Heading, degrees true, converted from radians.
257 Heading(f64),
258 /// A vessel's own name, from the bare `name` path -- the same path
259 /// on any context, self or otherwise, which is what makes this
260 /// useful for an AIS target: a position with no name attached is
261 /// just a dot on the chart.
262 VesselName(String),
263 /// An AIS (or other) target's own tracking status, `sensors.ais.status`
264 /// -- confirmed live against `sk-ais-status-plugin`, not a Signal K
265 /// specification path, but a real, already-observed convention worth
266 /// reading passively the same as anything else this crate did not
267 /// invent. See [`AisTargetStatus`]'s own doc.
268 AisTargetStatus(AisTargetStatus),
269 /// A CPA/TCPA collision alarm's own severity, from
270 /// `notifications.navigation.closestApproach` -- not a Signal K
271 /// specification path (`notifications.collision.*` is the schema's
272 /// own "Well Known Name" for this; no CPA plugin surveyed while this
273 /// was built actually uses it), but a real, already-observed
274 /// convention: both `signalk-derived-data` and
275 /// `signalk-ais-target-prioritizer` publish their own computed
276 /// CPA/TCPA alarm here, confirmed live against the latter (the one
277 /// a first client was built against -- see [`NotificationSeverity`]'s
278 /// own doc). Read passively, the same "worth reading, not worth
279 /// depending on being there" treatment [`Measurement::AisTargetStatus`]
280 /// already gets: a server with no such plugin simply never produces
281 /// this reading, and nothing downstream requires it.
282 ClosestApproachAlarm(NotificationSeverity),
283 /// The transponder class, from `sensors.ais.class`.
284 AisClass(AisClass),
285 /// What kind of vessel it says it is, `design.aisShipType`'s own
286 /// `name` -- "Cargo ship", "Sailing vessel". Only the name is kept;
287 /// the numeric id is the same fact in a code nobody reads.
288 AisShipType(String),
289 /// The VHF call sign, `communication.callsignVhf` -- read off that
290 /// flat path, or nested in a vessel-root update the way AIS static
291 /// data actually sends it; see [`Context`]'s own module and this
292 /// enum's `measurement_of` for which is which.
293 Callsign(String),
294 /// `signalk-ais-target-prioritizer`'s own closest-point-of-approach
295 /// figures for a target, from `navigation.closestApproach` -- the
296 /// numbers behind the alarm [`Measurement::ClosestApproachAlarm`]
297 /// carries only the severity of. Confirmed live: the plugin publishes
298 /// this object for every target it tracks, and **leaves `distance` and
299 /// `timeTo` out** of it whenever there is no approach to speak of (a
300 /// target moving away, or further than its own ceilings), so both are
301 /// `None` then and a reader must let that overwrite what it held, not
302 /// keep the last figure. Nothing here computes a CPA: this reads the
303 /// plugin's answer, as a client reads its alarm.
304 ClosestApproach {
305 /// Distance at closest approach, metres.
306 cpa_m: Option<f64>,
307 /// Seconds until it, when it lies ahead.
308 tcpa_s: Option<f64>,
309 },
310 /// A sounding, carrying what it was measured from.
311 Depth(Depth),
312 /// Transducer face to the bottom of the keel, metres. Configuration
313 /// rather than a measurement, but it arrives the same way.
314 TransducerToKeel(f64),
315 /// Water surface to the transducer face, metres. Likewise.
316 SurfaceToTransducer(f64),
317 /// The vessel's own maximum draft, metres. Static hull data rather than
318 /// a live reading, but Signal K sends it the same way -- once, on
319 /// `design.draft`, as part of the burst a server hands a client on
320 /// first subscribing to itself.
321 MaximumDraft(f64),
322 /// The vessel's own length overall, metres, from `design.length`'s own
323 /// `overall` field -- static hull data, same delivery as
324 /// [`Measurement::MaximumDraft`] and for the same reason.
325 DesignLength(f64),
326 /// The vessel's own beam (maximum width), metres, from `design.beam`.
327 /// Unlike [`Measurement::MaximumDraft`]/[`Measurement::DesignLength`],
328 /// the schema gives beam no sibling fields to choose among -- it is
329 /// already the one number this is.
330 DesignBeam(f64),
331 /// Apparent wind angle, degrees off the bow, positive to starboard --
332 /// converted from Signal K's radians, which already carry the same
333 /// convention the other way round ("negative to port").
334 WindAngleApparent(f64),
335 /// Apparent wind speed in knots, converted from metres per second.
336 WindSpeedApparent(f64),
337 /// True wind direction, degrees true -- an absolute bearing relative
338 /// to true north, not an angle off the bow, converted from Signal K's
339 /// radians. Published only by a server whose own instruments (or a
340 /// calculation plugin) have already done the wind-triangle work; see
341 /// [`WindAngleApparent`](Measurement::WindAngleApparent) for the raw
342 /// reading every installation has instead.
343 WindDirectionTrue(f64),
344 /// True wind speed in knots, converted from metres per second.
345 WindSpeedTrue(f64),
346 /// The anchor watch's own position, `navigation.anchor.position` --
347 /// `None` when the path is explicitly nulled, which is how
348 /// `signalk-anchoralarm-plugin` (and, per the Signal K convention it
349 /// follows, any other implementation of this path) reports the
350 /// anchor raised, confirmed live against a running server. Distinct
351 /// from every other `Measurement` here in that a `None` is itself
352 /// meaningful and must overwrite whatever a reader kept from before
353 /// -- there is no staleness to lean on the way there is for a fix
354 /// that simply stops arriving.
355 AnchorPosition(Option<Position>),
356 /// The anchor watch's own swing radius, `navigation.anchor.maxRadius`,
357 /// metres. `None` on the same explicit-null convention as
358 /// [`AnchorPosition`](Measurement::AnchorPosition), which a real
359 /// server nulls at the same time as the position.
360 AnchorMaxRadius(Option<f64>),
361 /// Where the vessel is navigating towards right now,
362 /// `navigation.course.nextPoint`'s own `position` -- Signal K's
363 /// Course API, confirmed live against the reference implementation
364 /// bundled with `signalk-server`. `None` when the whole `nextPoint`
365 /// is explicitly nulled, the same convention
366 /// [`AnchorPosition`](Measurement::AnchorPosition) uses: the server
367 /// sends exactly that the moment nothing is being followed any more
368 /// (course cleared, or never set), and that null is itself the
369 /// answer to "is a route currently active", not merely a value gone
370 /// stale.
371 CourseNextPoint(Option<Position>),
372 /// Where the vessel started navigating from, for whichever leg
373 /// [`CourseNextPoint`](Measurement::CourseNextPoint) is the far end
374 /// of -- `navigation.course.previousPoint`'s own `position`. A fixed
375 /// point once following begins (confirmed live: the server reports
376 /// the vessel's own position *at that moment*, `type:
377 /// "VesselPosition"`, not a continuously-updated "where I am now"),
378 /// which is what makes it usable as the `from` end of a cross-track
379 /// calculation at all -- a leg that moved with the vessel would
380 /// always measure zero off itself. `None` on the same explicit-null
381 /// convention [`CourseNextPoint`](Measurement::CourseNextPoint)
382 /// itself uses.
383 CoursePreviousPoint(Option<Position>),
384 /// The `href` of whatever route is actively being followed,
385 /// `navigation.course.activeRoute`'s own `href` field -- confirmed
386 /// live, the whole `activeRoute` object (`href`, `name`, `reverse`,
387 /// `pointIndex`, `pointTotal`) arrives as one delta at this path, not
388 /// nested the way [`CourseNextPoint`](Measurement::CourseNextPoint)'s
389 /// own `position` is. Only `href` is kept -- the rest names how far
390 /// along the route the vessel already is, which
391 /// [`CourseNextPoint`](Measurement::CourseNextPoint)/
392 /// [`CoursePreviousPoint`](Measurement::CoursePreviousPoint) already
393 /// answer more directly -- and it exists so a caller can fetch the
394 /// route's own full geometry to draw, the one thing following a
395 /// route cannot otherwise show: which waypoints lie ahead, past the
396 /// leg actually underway. `None` on the same explicit-null
397 /// convention as the other two: a real server nulls the whole
398 /// `activeRoute` object, not just this field, the moment nothing is
399 /// being followed.
400 CourseActiveRouteHref(Option<String>),
401 /// Something was created, updated, or deleted at
402 /// `resources.<resource_type>.<id>` -- confirmed live against the
403 /// server's own `buildDeltaMsg` (`dist/api/resources/index.js`):
404 /// broadcast on every `POST`/`PUT`/`DELETE` through the Resources
405 /// API, with no `context` field at all (resources are not
406 /// per-vessel the way navigation data is), which the `None` arm of
407 /// this module's own context handling already treats as self -- see
408 /// [`parse`]'s own doc.
409 ///
410 /// Carries no more than "something changed here", not the
411 /// resource's own new value: that arrives as arbitrary JSON this
412 /// crate has no business parsing -- `signalk` sits below
413 /// `tracks`/`routes`/`waypoints` in this workspace's own one-way
414 /// dependency order (see this module's own top doc), so it cannot
415 /// know their shapes, only that a path matching this pattern
416 /// exists. A caller reacts by asking again through the REST API it
417 /// already has working (the same request a mariner's own Refresh
418 /// button already makes) rather than a second, parallel path
419 /// parsing this JSON that could drift from the first.
420 ResourceChanged {
421 /// `"tracks"`, `"routes"`, `"waypoints"`, or any other resource
422 /// type collection name -- free text, since a server's own
423 /// custom collections are not a fixed enum either (see
424 /// `@signalk/resources-provider`'s own "custom" config).
425 resource_type: String,
426 /// The uuid of the specific resource that changed. Not read by
427 /// any caller yet -- every one so far reacts by refreshing its
428 /// whole list rather than one entry -- carried anyway since the
429 /// delta already has it for free, for a future caller that
430 /// wants to react to just the one.
431 id: String,
432 },
433}
434
435/// One delta message, read.
436#[derive(Debug, Clone, PartialEq)]
437pub struct Delta {
438 /// Whose it is.
439 pub context: Context,
440 /// What it carried that this crate understands.
441 pub readings: Vec<Reading>,
442 /// A `performance.activePolarData` update, verbatim, when this
443 /// delta's own values included one.
444 ///
445 /// Kept apart from `readings`/[`Measurement`] entirely rather than
446 /// as another variant of that enum: a polar is a nested table's
447 /// worth of JSON, not the small `Copy` scalar every other
448 /// [`Measurement`] carries, and folding it in would cost that enum
449 /// its `Copy` -- and every existing match site the ergonomics that
450 /// comes with -- for the one path nothing else needs it for.
451 /// [`crate::polar::parse`] turns this into a usable
452 /// [`crate::polar::PolarResource`]; this crate only carries it as
453 /// far as the wire shape, the same distance it keeps from every
454 /// other value here.
455 pub active_polar: Option<serde_json::Value>,
456}
457
458impl Delta {
459 /// Whether this is about own ship.
460 #[must_use]
461 pub fn is_self(&self) -> bool {
462 self.context == Context::SelfVessel
463 }
464}
465
466/// Reads one delta message.
467///
468/// Returns `None` only when the text is not a Signal K delta at all -- not
469/// when it carries nothing useful, which is an ordinary and frequent state
470/// of affairs.
471#[must_use]
472pub fn parse(message: &str, self_id: Option<&str>) -> Option<Delta> {
473 let value: serde_json::Value = serde_json::from_str(message).ok()?;
474 let updates = value.get("updates")?.as_array()?;
475
476 let context = match value.get("context").and_then(serde_json::Value::as_str) {
477 // A server talking about the boat it runs on says "vessels.self",
478 // or names it in full. Both mean own ship, and a client that only
479 // understood one of them would work on one server and not the next.
480 None | Some("vessels.self") => Context::SelfVessel,
481 Some(context) => match self_id {
482 Some(id) if context == id || context == format!("vessels.{id}") => Context::SelfVessel,
483 _ => Context::Other(context.to_owned()),
484 },
485 };
486
487 let mut readings = Vec::new();
488 let mut active_polar = None;
489 for update in updates {
490 let source = source_of(update);
491 let timestamp = update
492 .get("timestamp")
493 .and_then(serde_json::Value::as_str)
494 .map(str::to_owned);
495
496 let Some(values) = update.get("values").and_then(serde_json::Value::as_array) else {
497 continue;
498 };
499 for entry in values {
500 let Some(path) = entry.get("path").and_then(serde_json::Value::as_str) else {
501 continue;
502 };
503 let Some(value) = entry.get("value") else {
504 continue;
505 };
506 if path == "performance.activePolarData" {
507 active_polar = Some(value.clone());
508 continue;
509 }
510 if let Some(measurement) = measurement_of(path, value) {
511 readings.push(Reading {
512 source: source.clone(),
513 timestamp: timestamp.clone(),
514 measurement,
515 });
516 }
517 }
518 }
519
520 Some(Delta { context, readings, active_polar })
521}
522
523/// The label of whatever produced an update.
524fn source_of(update: &serde_json::Value) -> String {
525 if let Some(source) = update.get("$source").and_then(serde_json::Value::as_str) {
526 return source.to_owned();
527 }
528 update
529 .get("source")
530 .and_then(|source| source.get("label"))
531 .and_then(serde_json::Value::as_str)
532 .unwrap_or("unknown")
533 .to_owned()
534}
535
536/// One path and value, in this workspace's units.
537fn measurement_of(path: &str, value: &serde_json::Value) -> Option<Measurement> {
538 if let Some(reading) = resource_changed(path) {
539 return Some(reading);
540 }
541 match path {
542 "navigation.position" => {
543 let lat = value.get("latitude")?.as_f64()?;
544 let lon = value.get("longitude")?.as_f64()?;
545 let position = Position::new(lat, lon);
546 // A position off the earth is a broken sensor, not a fix. Let
547 // it through and it lands on the chart as a vessel somewhere
548 // impossible, which is harder to diagnose than nothing at all.
549 position
550 .is_valid()
551 .then_some(Measurement::Position(position))
552 }
553 "navigation.courseOverGroundTrue" => Some(Measurement::CourseOverGround(
554 nav_math::angle::norm_360(value.as_f64()?.to_degrees()),
555 )),
556 "navigation.headingTrue" => Some(Measurement::Heading(nav_math::angle::norm_360(
557 value.as_f64()?.to_degrees(),
558 ))),
559 "navigation.speedOverGround" => Some(Measurement::SpeedOverGround(
560 value.as_f64()? * KNOTS_PER_METRE_PER_SECOND,
561 )),
562 "name" => value.as_str().map(|name| Measurement::VesselName(name.to_owned())),
563 // What a real server sends for an AIS target's static data: an
564 // update on the vessel's *root*, an empty path with an object as
565 // the value -- `{"path":"","value":{"name":"MSC AURORA"}}`, or,
566 // in its own separate fragment, a call sign nested the identical
567 // way -- `{"path":"","value":{"communication":{"callsignVhf":
568 // "IBQY"}}}` -- and separately `{"mmsi":"..."}`, which is nothing
569 // to read here. Confirmed live against signalk-server decoding
570 // real `!AIVDM` type 5 and 24A/24B messages. The bare `name` and
571 // `communication.callsignVhf` paths below are what a plugin or a
572 // data-model write produce, not what AIS does -- a fragment ever
573 // carries one of these keys, never several, so trying `name`
574 // first and falling back costs nothing on a fragment that turns
575 // out to carry the other.
576 "" => value
577 .get("name")
578 .and_then(serde_json::Value::as_str)
579 .map(|name| Measurement::VesselName(name.to_owned()))
580 .or_else(|| {
581 value
582 .get("communication")
583 .and_then(|communication| communication.get("callsignVhf"))
584 .and_then(serde_json::Value::as_str)
585 .map(|sign| Measurement::Callsign(sign.to_owned()))
586 }),
587 "sensors.ais.status" => value
588 .as_str()
589 .and_then(AisTargetStatus::from_wire)
590 .map(Measurement::AisTargetStatus),
591 "sensors.ais.class" => value.as_str().and_then(AisClass::from_wire).map(Measurement::AisClass),
592 "design.aisShipType" => value
593 .get("name")
594 .and_then(serde_json::Value::as_str)
595 .map(|name| Measurement::AisShipType(name.to_owned())),
596 "communication.callsignVhf" => value.as_str().map(|sign| Measurement::Callsign(sign.to_owned())),
597 "navigation.closestApproach" if value.is_object() => Some(Measurement::ClosestApproach {
598 cpa_m: value.get("distance").and_then(serde_json::Value::as_f64),
599 tcpa_s: value.get("timeTo").and_then(serde_json::Value::as_f64),
600 }),
601 // The standard Notification shape (`{state, method, message,
602 // ...}`) -- only `state` has a use here, see
603 // Measurement::ClosestApproachAlarm's own doc for the path.
604 "notifications.navigation.closestApproach" => value
605 .get("state")
606 .and_then(serde_json::Value::as_str)
607 .and_then(NotificationSeverity::from_wire)
608 .map(Measurement::ClosestApproachAlarm),
609 // All three are read, each keeping the reference it arrived with.
610 // A server sends whichever its instruments produce: `DBT` becomes
611 // `belowTransducer` and nothing else, and that is the common case on
612 // a boat with older sounders.
613 "environment.depth.belowTransducer" => Some(Measurement::Depth(Depth {
614 metres: value.as_f64()?,
615 reference: Sounding::BelowTransducer,
616 })),
617 "environment.depth.belowKeel" => Some(Measurement::Depth(Depth {
618 metres: value.as_f64()?,
619 reference: Sounding::BelowKeel,
620 })),
621 "environment.depth.belowSurface" => Some(Measurement::Depth(Depth {
622 metres: value.as_f64()?,
623 reference: Sounding::BelowSurface,
624 })),
625 "environment.depth.transducerToKeel" => {
626 Some(Measurement::TransducerToKeel(value.as_f64()?))
627 }
628 "environment.depth.surfaceToTransducer" => {
629 Some(Measurement::SurfaceToTransducer(value.as_f64()?))
630 }
631 // An object, not a bare number -- the schema also defines minimum,
632 // current and canoe draft, none of which any server seen so far has
633 // ever actually populated. Reading only maximum is a deliberate
634 // choice, not an oversight: it is the one number that answers "how
635 // deep does this vessel sit at its worst", which is what a safety
636 // margin has to be measured against.
637 "design.draft" => value
638 .get("maximum")
639 .and_then(serde_json::Value::as_f64)
640 .map(Measurement::MaximumDraft),
641 // Same shape as design.draft just above, reading only `overall`
642 // out of the schema's own length/hull/waterline trio -- length
643 // overall is the one figure a hull outline on the chart needs.
644 "design.length" => value
645 .get("overall")
646 .and_then(serde_json::Value::as_f64)
647 .map(Measurement::DesignLength),
648 "design.beam" => value.as_f64().map(Measurement::DesignBeam),
649 // The two ends of the wind triangle a client can actually rely on
650 // seeing: apparent wind, which every instrument that measures wind
651 // at all reports, and true wind direction, which only a server that
652 // has already done the triangle itself (its own instrument, or a
653 // calculation plugin) publishes. Both matter here rather than only
654 // the derived one, because a caller with no true wind on the wire
655 // still has apparent to compute it from -- see `nav_math::wind`.
656 "environment.wind.angleApparent" => Some(Measurement::WindAngleApparent(
657 nav_math::angle::norm_180(value.as_f64()?.to_degrees()),
658 )),
659 "environment.wind.speedApparent" => Some(Measurement::WindSpeedApparent(
660 value.as_f64()? * KNOTS_PER_METRE_PER_SECOND,
661 )),
662 "environment.wind.directionTrue" => Some(Measurement::WindDirectionTrue(
663 nav_math::angle::norm_360(value.as_f64()?.to_degrees()),
664 )),
665 "environment.wind.speedTrue" => Some(Measurement::WindSpeedTrue(
666 value.as_f64()? * KNOTS_PER_METRE_PER_SECOND,
667 )),
668 // Explicit `null` clears (see AnchorPosition's own doc); anything
669 // else has to parse as a real position or this reading is
670 // dropped like any other malformed one, not read as a clear.
671 "navigation.anchor.position" => {
672 if value.is_null() {
673 return Some(Measurement::AnchorPosition(None));
674 }
675 let lat = value.get("latitude")?.as_f64()?;
676 let lon = value.get("longitude")?.as_f64()?;
677 let position = Position::new(lat, lon);
678 position
679 .is_valid()
680 .then_some(Measurement::AnchorPosition(Some(position)))
681 }
682 "navigation.anchor.maxRadius" => {
683 if value.is_null() {
684 return Some(Measurement::AnchorMaxRadius(None));
685 }
686 value.as_f64().map(|radius| Measurement::AnchorMaxRadius(Some(radius)))
687 }
688 // Explicit `null` clears (see CourseNextPoint's own doc). Unlike
689 // `navigation.anchor.position`, the position a real value carries
690 // sits nested one level down -- the Course API's own
691 // `NextPreviousPoint` object has `href`/`type`/`position`/`name`
692 // fields, and `position` is the only one this crate has a use
693 // for so far.
694 "navigation.course.nextPoint" => {
695 if value.is_null() {
696 return Some(Measurement::CourseNextPoint(None));
697 }
698 let point = value.get("position")?;
699 let lat = point.get("latitude")?.as_f64()?;
700 let lon = point.get("longitude")?.as_f64()?;
701 let position = Position::new(lat, lon);
702 position
703 .is_valid()
704 .then_some(Measurement::CourseNextPoint(Some(position)))
705 }
706 "navigation.course.previousPoint" => {
707 if value.is_null() {
708 return Some(Measurement::CoursePreviousPoint(None));
709 }
710 let point = value.get("position")?;
711 let lat = point.get("latitude")?.as_f64()?;
712 let lon = point.get("longitude")?.as_f64()?;
713 let position = Position::new(lat, lon);
714 position
715 .is_valid()
716 .then_some(Measurement::CoursePreviousPoint(Some(position)))
717 }
718 "navigation.course.activeRoute" => {
719 if value.is_null() {
720 return Some(Measurement::CourseActiveRouteHref(None));
721 }
722 let href = value.get("href")?.as_str()?;
723 (!href.is_empty()).then(|| Measurement::CourseActiveRouteHref(Some(href.to_owned())))
724 }
725 _ => None,
726 }
727}
728
729/// `resources.<resource_type>.<id>`, or `None` for anything else --
730/// see [`Measurement::ResourceChanged`]'s own doc for why this is a
731/// prefix match, not one more literal arm in [`measurement_of`]'s own
732/// `match`: the resource type and id are both part of the path itself,
733/// not a fixed string this crate could enumerate.
734fn resource_changed(path: &str) -> Option<Measurement> {
735 let rest = path.strip_prefix("resources.")?;
736 let (resource_type, id) = rest.split_once('.')?;
737 (!resource_type.is_empty() && !id.is_empty())
738 .then(|| Measurement::ResourceChanged { resource_type: resource_type.to_owned(), id: id.to_owned() })
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744
745 fn one_value(path: &str, value: &str) -> String {
746 format!(
747 r#"{{"context":"vessels.self","updates":[{{"$source":"n2k.1",
748 "timestamp":"2026-09-13T09:00:00Z",
749 "values":[{{"path":"{path}","value":{value}}}]}}]}}"#
750 )
751 }
752
753 #[test]
754 fn a_course_arrives_in_radians_and_is_read_in_degrees() {
755 // Half pi on the wire is due east. Read as degrees it would be 1.57
756 // degrees -- almost due north, and plausible enough to go unnoticed.
757 let delta = parse(
758 &one_value("navigation.courseOverGroundTrue", "1.5707963268"),
759 None,
760 )
761 .expect("a delta");
762 let Measurement::CourseOverGround(deg) = delta.readings[0].measurement else {
763 panic!("not a course: {:?}", delta.readings[0]);
764 };
765 assert!((deg - 90.0).abs() < 1e-6, "got {deg}");
766 }
767
768 #[test]
769 fn a_speed_arrives_in_metres_per_second_and_is_read_in_knots() {
770 let delta =
771 parse(&one_value("navigation.speedOverGround", "5.144444"), None).expect("a delta");
772 let Measurement::SpeedOverGround(knots) = delta.readings[0].measurement else {
773 panic!("not a speed");
774 };
775 assert!((knots - 10.0).abs() < 1e-4, "got {knots}");
776 }
777
778 #[test]
779 fn a_position_arrives_in_degrees_and_stays_in_degrees() {
780 let delta = parse(
781 &one_value(
782 "navigation.position",
783 r#"{"latitude":45.55,"longitude":13.73}"#,
784 ),
785 None,
786 )
787 .expect("a delta");
788 assert_eq!(
789 delta.readings[0].measurement,
790 Measurement::Position(Position::new(45.55, 13.73))
791 );
792 }
793
794 #[test]
795 fn a_heading_wraps_into_the_bearing_range() {
796 // Signal K permits values outside 0..2pi; a bearing of -10 degrees
797 // is not a bearing anything else in this workspace accepts.
798 let delta =
799 parse(&one_value("navigation.headingTrue", "-0.1745329"), None).expect("a delta");
800 let Measurement::Heading(deg) = delta.readings[0].measurement else {
801 panic!("not a heading");
802 };
803 assert!((deg - 350.0).abs() < 1e-4, "got {deg}");
804 }
805
806 #[test]
807 fn an_anchor_position_arrives_the_same_way_a_fix_does() {
808 let delta = parse(
809 &one_value(
810 "navigation.anchor.position",
811 r#"{"latitude":45.55,"longitude":13.73}"#,
812 ),
813 None,
814 )
815 .expect("a delta");
816 assert_eq!(
817 delta.readings[0].measurement,
818 Measurement::AnchorPosition(Some(Position::new(45.55, 13.73)))
819 );
820 }
821
822 #[test]
823 fn an_explicitly_nulled_anchor_position_is_read_as_raised_not_dropped() {
824 // Confirmed live against a real signalk-anchoralarm-plugin: raising
825 // the anchor nulls navigation.anchor.position rather than simply
826 // going quiet, and that null has to survive as far as Measurement
827 // -- unlike an unparseable value, which is dropped instead.
828 let delta = parse(&one_value("navigation.anchor.position", "null"), None).expect("a delta");
829 assert_eq!(delta.readings[0].measurement, Measurement::AnchorPosition(None));
830 }
831
832 #[test]
833 fn an_anchor_max_radius_arrives_in_metres() {
834 let delta = parse(&one_value("navigation.anchor.maxRadius", "30"), None).expect("a delta");
835 assert_eq!(delta.readings[0].measurement, Measurement::AnchorMaxRadius(Some(30.0)));
836 }
837
838 #[test]
839 fn an_explicitly_nulled_anchor_max_radius_is_read_as_raised_not_dropped() {
840 let delta = parse(&one_value("navigation.anchor.maxRadius", "null"), None).expect("a delta");
841 assert_eq!(delta.readings[0].measurement, Measurement::AnchorMaxRadius(None));
842 }
843
844 #[test]
845 fn a_course_next_point_reads_the_position_nested_inside_it() {
846 // Confirmed live against signalk-server's own Course API: the whole
847 // NextPreviousPoint object arrives on one path
848 // (navigation.course.nextPoint), position nested one level down
849 // alongside href/type/name -- unlike navigation.anchor.position,
850 // which is the bare coordinate object itself.
851 let delta = parse(
852 &one_value(
853 "navigation.course.nextPoint",
854 r#"{"type":"RoutePoint","position":{"latitude":45.55,"longitude":13.73}}"#,
855 ),
856 None,
857 )
858 .expect("a delta");
859 assert_eq!(
860 delta.readings[0].measurement,
861 Measurement::CourseNextPoint(Some(Position::new(45.55, 13.73)))
862 );
863 }
864
865 #[test]
866 fn an_explicitly_nulled_course_next_point_is_read_as_not_following_not_dropped() {
867 // The server sends exactly this the moment a route is cleared or
868 // never set -- the one signal this crate has for "nothing is
869 // currently being followed".
870 let delta = parse(&one_value("navigation.course.nextPoint", "null"), None).expect("a delta");
871 assert_eq!(delta.readings[0].measurement, Measurement::CourseNextPoint(None));
872 }
873
874 #[test]
875 fn a_course_previous_point_reads_the_position_nested_inside_it() {
876 // Confirmed live: the server reports the vessel's own position at
877 // the moment following began, type "VesselPosition" -- a fixed
878 // point, not a running "here I am now".
879 let delta = parse(
880 &one_value(
881 "navigation.course.previousPoint",
882 r#"{"type":"VesselPosition","position":{"latitude":45.79,"longitude":13.56}}"#,
883 ),
884 None,
885 )
886 .expect("a delta");
887 assert_eq!(
888 delta.readings[0].measurement,
889 Measurement::CoursePreviousPoint(Some(Position::new(45.79, 13.56)))
890 );
891 }
892
893 #[test]
894 fn an_explicitly_nulled_course_previous_point_is_read_as_not_following_not_dropped() {
895 let delta = parse(&one_value("navigation.course.previousPoint", "null"), None).expect("a delta");
896 assert_eq!(delta.readings[0].measurement, Measurement::CoursePreviousPoint(None));
897 }
898
899 #[test]
900 fn an_active_route_reads_the_href_out_of_the_whole_object() {
901 // Confirmed live: the whole activeRoute object (href, name,
902 // reverse, pointIndex, pointTotal) arrives as one delta here, not
903 // nested the way nextPoint/previousPoint's own position is.
904 let delta = parse(
905 &one_value(
906 "navigation.course.activeRoute",
907 r#"{"href":"/resources/routes/b6ba1e44-169e-4324-b685-9ed2a38175f8","name":"Test","reverse":false,"pointIndex":0,"pointTotal":2}"#,
908 ),
909 None,
910 )
911 .expect("a delta");
912 assert_eq!(
913 delta.readings[0].measurement,
914 Measurement::CourseActiveRouteHref(Some(
915 "/resources/routes/b6ba1e44-169e-4324-b685-9ed2a38175f8".to_owned()
916 ))
917 );
918 }
919
920 #[test]
921 fn an_explicitly_nulled_active_route_is_read_as_not_following_not_dropped() {
922 let delta = parse(&one_value("navigation.course.activeRoute", "null"), None).expect("a delta");
923 assert_eq!(delta.readings[0].measurement, Measurement::CourseActiveRouteHref(None));
924 }
925
926 #[test]
927 fn a_resource_change_reads_the_type_and_id_out_of_the_path_itself() {
928 // The server's own buildDeltaMsg sends the resource's whole new
929 // value here too -- deliberately ignored, see
930 // Measurement::ResourceChanged's own doc.
931 let delta = parse(
932 &one_value(
933 "resources.tracks.94052456-65fa-48ce-a85d-41b78a9d2111",
934 r#"{"name":"Passage to Piran"}"#,
935 ),
936 None,
937 )
938 .expect("a delta");
939 assert_eq!(
940 delta.readings[0].measurement,
941 Measurement::ResourceChanged {
942 resource_type: "tracks".to_owned(),
943 id: "94052456-65fa-48ce-a85d-41b78a9d2111".to_owned(),
944 }
945 );
946 }
947
948 #[test]
949 fn a_resource_deletion_is_read_the_same_way_a_change_is() {
950 // The server's own convention for "this was deleted": the same
951 // path, value null -- deliberately not distinguished from a
952 // create/update here, see ResourceChanged's own doc for why a
953 // caller reacts identically either way (asking again).
954 let delta = parse(&one_value("resources.tracks.94052456-65fa-48ce-a85d-41b78a9d2111", "null"), None)
955 .expect("a delta");
956 assert_eq!(
957 delta.readings[0].measurement,
958 Measurement::ResourceChanged {
959 resource_type: "tracks".to_owned(),
960 id: "94052456-65fa-48ce-a85d-41b78a9d2111".to_owned(),
961 }
962 );
963 }
964
965 #[test]
966 fn a_resources_path_with_nothing_after_the_type_is_not_a_resource_change() {
967 assert!(resource_changed("resources.tracks").is_none());
968 assert!(resource_changed("resources.tracks.").is_none());
969 }
970
971 #[test]
972 fn a_position_off_the_earth_is_not_a_fix() {
973 let delta = parse(
974 &one_value(
975 "navigation.position",
976 r#"{"latitude":91.0,"longitude":13.0}"#,
977 ),
978 None,
979 )
980 .expect("a delta");
981 assert!(delta.readings.is_empty(), "{:?}", delta.readings);
982 }
983
984 #[test]
985 fn paths_this_crate_has_no_use_for_are_skipped_and_not_refused() {
986 // A real server sends dozens of these. Failing on the first unknown
987 // one would break every consumer the day the boat gains a sensor.
988 let delta = parse(
989 &one_value("electrical.batteries.house.voltage", "12.6"),
990 None,
991 )
992 .expect("still a delta");
993 assert!(delta.readings.is_empty());
994 }
995
996 #[test]
997 fn which_vessel_a_delta_is_about_is_never_guessed() {
998 // AIS targets arrive on the same socket as own ship. Reading one as
999 // the other puts another boat's position under the own-ship symbol.
1000 let ais = parse(
1001 r#"{"context":"vessels.urn:mrn:imo:mmsi:238123456","updates":[
1002 {"$source":"ais.0","values":[
1003 {"path":"navigation.position","value":{"latitude":45.5,"longitude":13.6}}]}]}"#,
1004 Some("urn:mrn:signalk:uuid:self"),
1005 )
1006 .expect("a delta");
1007 assert!(!ais.is_self());
1008 assert_eq!(
1009 ais.context,
1010 Context::Other("vessels.urn:mrn:imo:mmsi:238123456".to_owned())
1011 );
1012 }
1013
1014 #[test]
1015 fn own_ship_is_recognised_by_either_name_the_servers_use() {
1016 let shorthand =
1017 parse(&one_value("navigation.speedOverGround", "1.0"), None).expect("a delta");
1018 assert!(shorthand.is_self());
1019
1020 let in_full = parse(
1021 r#"{"context":"vessels.urn:mrn:signalk:uuid:aaa","updates":[
1022 {"$source":"n2k.1","values":[
1023 {"path":"navigation.speedOverGround","value":1.0}]}]}"#,
1024 Some("urn:mrn:signalk:uuid:aaa"),
1025 )
1026 .expect("a delta");
1027 assert!(in_full.is_self());
1028 }
1029
1030 #[test]
1031 fn the_source_label_survives_in_both_the_forms_servers_send_it() {
1032 let short = parse(&one_value("navigation.speedOverGround", "1.0"), None).expect("a delta");
1033 assert_eq!(short.readings[0].source, "n2k.1");
1034
1035 let long = parse(
1036 r#"{"context":"vessels.self","updates":[
1037 {"source":{"label":"cockpit-gps","type":"NMEA0183"},"values":[
1038 {"path":"navigation.speedOverGround","value":1.0}]}]}"#,
1039 None,
1040 )
1041 .expect("a delta");
1042 assert_eq!(long.readings[0].source, "cockpit-gps");
1043 }
1044
1045 #[test]
1046 fn a_reading_of_unknown_origin_is_labelled_rather_than_dropped() {
1047 let delta = parse(
1048 r#"{"context":"vessels.self","updates":[{"values":[
1049 {"path":"navigation.speedOverGround","value":1.0}]}]}"#,
1050 None,
1051 )
1052 .expect("a delta");
1053 assert_eq!(delta.readings[0].source, "unknown");
1054 }
1055
1056 #[test]
1057 fn several_values_in_one_update_all_arrive() {
1058 let delta = parse(
1059 r#"{"context":"vessels.self","updates":[{"$source":"n2k.1","values":[
1060 {"path":"navigation.position","value":{"latitude":45.5,"longitude":13.6}},
1061 {"path":"navigation.speedOverGround","value":3.0},
1062 {"path":"environment.depth.belowKeel","value":4.2}]}]}"#,
1063 None,
1064 )
1065 .expect("a delta");
1066 assert_eq!(delta.readings.len(), 3);
1067 }
1068
1069 #[test]
1070 fn an_active_polar_update_is_carried_apart_from_the_ordinary_readings() {
1071 let delta = parse(
1072 &one_value(
1073 "performance.activePolarData",
1074 r#"{"id":"x","name":"x","windData":[]}"#,
1075 ),
1076 None,
1077 )
1078 .expect("a delta");
1079 assert!(delta.readings.is_empty(), "not folded into an ordinary Measurement");
1080 assert_eq!(
1081 delta.active_polar,
1082 Some(serde_json::json!({"id": "x", "name": "x", "windData": []}))
1083 );
1084 }
1085
1086 #[test]
1087 fn no_active_polar_update_leaves_it_none() {
1088 let delta = parse(&one_value("navigation.speedOverGround", "1.0"), None).expect("a delta");
1089 assert_eq!(delta.active_polar, None);
1090 }
1091
1092 #[test]
1093 fn something_that_is_not_a_delta_is_not_read_as_an_empty_one() {
1094 // A subscription acknowledgement or a hello message must not look
1095 // like a delta that happened to carry nothing.
1096 assert!(parse("{\"name\":\"signalk-server\",\"version\":\"2.0\"}", None).is_none());
1097 assert!(parse("not json", None).is_none());
1098 }
1099
1100 #[test]
1101 fn a_sounding_keeps_the_reference_it_arrived_with() {
1102 // The common case on a boat with older sounders: NMEA `DBT` has no
1103 // offset field, so the server can only ever say belowTransducer.
1104 let delta = parse(
1105 &one_value("environment.depth.belowTransducer", "10.44"),
1106 None,
1107 )
1108 .expect("a delta");
1109 assert_eq!(
1110 delta.readings[0].measurement,
1111 Measurement::Depth(Depth {
1112 metres: 10.44,
1113 reference: Sounding::BelowTransducer,
1114 })
1115 );
1116 }
1117
1118 #[test]
1119 fn transducer_depth_does_not_become_keel_depth_without_the_geometry() {
1120 // The whole point. Nothing is known about where the transducer sits,
1121 // so there is no answer -- rather than the answer "10.44 m under the
1122 // keel", which would be over-reporting the water by the length of an
1123 // unstated offset.
1124 let sounding = Depth {
1125 metres: 10.44,
1126 reference: Sounding::BelowTransducer,
1127 };
1128 assert!(
1129 sounding
1130 .to(Sounding::BelowKeel, Offsets::default())
1131 .is_none()
1132 );
1133 }
1134
1135 #[test]
1136 fn a_stated_offset_is_applied_once_and_in_the_safe_direction() {
1137 // 0.8 m from the transducer down to the keel: less water under the
1138 // keel than under the transducer, never more.
1139 let offsets = Offsets {
1140 transducer_to_keel: Some(0.8),
1141 surface_to_transducer: Some(0.4),
1142 };
1143 let sounding = Depth {
1144 metres: 10.44,
1145 reference: Sounding::BelowTransducer,
1146 };
1147
1148 let keel = sounding.to(Sounding::BelowKeel, offsets).expect("geometry");
1149 assert!((keel.metres - 9.64).abs() < 1e-9);
1150 assert_eq!(keel.reference, Sounding::BelowKeel);
1151
1152 // And from the surface there is more water than the instrument saw,
1153 // by however deep the transducer is.
1154 let surface = sounding
1155 .to(Sounding::BelowSurface, offsets)
1156 .expect("geometry");
1157 assert!((surface.metres - 10.84).abs() < 1e-9);
1158 }
1159
1160 #[test]
1161 fn converting_back_and_forth_does_not_apply_the_offset_twice() {
1162 // Applying it twice is the classic installation fault -- the sounder
1163 // already corrected and the display corrects again. Round-tripping
1164 // has to land back on the measured number.
1165 let offsets = Offsets {
1166 transducer_to_keel: Some(0.8),
1167 surface_to_transducer: Some(0.4),
1168 };
1169 let measured = Depth {
1170 metres: 10.44,
1171 reference: Sounding::BelowTransducer,
1172 };
1173 let there_and_back = measured
1174 .to(Sounding::BelowKeel, offsets)
1175 .expect("geometry")
1176 .to(Sounding::BelowTransducer, offsets)
1177 .expect("geometry");
1178 assert!((there_and_back.metres - measured.metres).abs() < 1e-9);
1179 }
1180
1181 #[test]
1182 fn a_keel_already_in_the_bottom_is_reported_rather_than_hidden() {
1183 // Clamping this to zero would turn "you are aground" into "you have
1184 // no water", which reads like a sensor fault instead of a fact.
1185 let offsets = Offsets {
1186 transducer_to_keel: Some(0.8),
1187 surface_to_transducer: None,
1188 };
1189 let keel = Depth {
1190 metres: 0.5,
1191 reference: Sounding::BelowTransducer,
1192 }
1193 .to(Sounding::BelowKeel, offsets)
1194 .expect("geometry");
1195 assert!(keel.metres < 0.0);
1196 }
1197
1198 #[test]
1199 fn the_sounder_geometry_is_read_off_the_wire_too() {
1200 // A server that has been told the installation sends it, and then
1201 // nothing has to be configured twice.
1202 let delta = parse(
1203 &one_value("environment.depth.transducerToKeel", "0.8"),
1204 None,
1205 )
1206 .expect("a delta");
1207 assert_eq!(
1208 delta.readings[0].measurement,
1209 Measurement::TransducerToKeel(0.8)
1210 );
1211 }
1212
1213 #[test]
1214 fn the_vessels_maximum_draft_is_read_off_the_wire() {
1215 // Captured verbatim from a real server's answer to `PUT
1216 // /skserver/vessel`: an object, not a bare number, and only
1217 // `maximum` populated -- current, minimum and canoe are all in the
1218 // schema and none of them ever arrive in practice.
1219 let delta = parse(&one_value("design.draft", r#"{"maximum":2}"#), None)
1220 .expect("a delta");
1221 assert_eq!(delta.readings[0].measurement, Measurement::MaximumDraft(2.0));
1222 }
1223
1224 #[test]
1225 fn the_vessels_length_overall_is_read_off_the_wire() {
1226 let delta = parse(&one_value("design.length", r#"{"overall":10.5,"hull":10.2}"#), None)
1227 .expect("a delta");
1228 assert_eq!(delta.readings[0].measurement, Measurement::DesignLength(10.5));
1229 }
1230
1231 #[test]
1232 fn a_length_object_with_no_overall_is_not_a_measurement() {
1233 let delta = parse(&one_value("design.length", r#"{"hull":10.2}"#), None).expect("a delta");
1234 assert!(delta.readings.is_empty());
1235 }
1236
1237 #[test]
1238 fn the_vessels_beam_is_read_off_the_wire() {
1239 // A bare number, unlike design.draft/design.length -- the schema
1240 // gives beam no sibling fields at all.
1241 let delta = parse(&one_value("design.beam", "3.6"), None).expect("a delta");
1242 assert_eq!(delta.readings[0].measurement, Measurement::DesignBeam(3.6));
1243 }
1244
1245 #[test]
1246 fn an_ais_targets_class_type_and_call_sign_are_read() {
1247 let delta = parse(
1248 r#"{"context":"vessels.urn:mrn:imo:mmsi:247111000","updates":[
1249 {"$source":"ais.0","values":[
1250 {"path":"sensors.ais.class","value":"B"},
1251 {"path":"design.aisShipType","value":{"id":70,"name":"Cargo ship"}},
1252 {"path":"communication.callsignVhf","value":"IABC"},
1253 {"path":"sensors.ais.class","value":"C"}]}]}"#,
1254 None,
1255 )
1256 .expect("a delta");
1257 let readings: Vec<_> = delta.readings.into_iter().map(|r| r.measurement).collect();
1258 assert_eq!(
1259 readings,
1260 [
1261 Measurement::AisClass(AisClass::B),
1262 Measurement::AisShipType("Cargo ship".to_owned()),
1263 Measurement::Callsign("IABC".to_owned()),
1264 ],
1265 "an unknown class is skipped, not an error"
1266 );
1267 }
1268
1269 #[test]
1270 fn closest_approach_figures_are_read_and_may_both_be_absent() {
1271 // As the prioritizer publishes it: figures only when there is an
1272 // approach, the object still there when there is not.
1273 let approaching = parse(
1274 r#"{"context":"vessels.urn:mrn:imo:mmsi:1","updates":[{"$source":"p","values":[
1275 {"path":"navigation.closestApproach","value":
1276 {"distance":370.4,"timeTo":600,"range":2000,"bearing":90,
1277 "collisionAlarmType":null,"collisionAlarmState":null}}]}]}"#,
1278 None,
1279 )
1280 .expect("a delta");
1281 assert_eq!(
1282 approaching.readings[0].measurement,
1283 Measurement::ClosestApproach { cpa_m: Some(370.4), tcpa_s: Some(600.0) }
1284 );
1285
1286 let receding = parse(
1287 r#"{"context":"vessels.urn:mrn:imo:mmsi:1","updates":[{"$source":"p","values":[
1288 {"path":"navigation.closestApproach","value":{"range":2000,"bearing":90}}]}]}"#,
1289 None,
1290 )
1291 .expect("a delta");
1292 assert_eq!(
1293 receding.readings[0].measurement,
1294 Measurement::ClosestApproach { cpa_m: None, tcpa_s: None }
1295 );
1296 }
1297
1298 #[test]
1299 fn an_ais_targets_name_is_read_off_a_vessel_root_update() {
1300 // Exactly as signalk-server sends it for an AIS static-data
1301 // message: empty path, object value. The MMSI arrives the same
1302 // way, in an update of its own, and is not a name.
1303 let named = parse(
1304 r#"{"context":"vessels.urn:mrn:imo:mmsi:247111000","updates":[
1305 {"$source":"ais-faker.AI","values":[
1306 {"path":"","value":{"name":"MSC AURORA"}}]}]}"#,
1307 Some("urn:mrn:signalk:uuid:self"),
1308 )
1309 .expect("a delta");
1310 assert_eq!(named.readings[0].measurement, Measurement::VesselName("MSC AURORA".to_owned()));
1311
1312 let mmsi_only = parse(
1313 r#"{"context":"vessels.urn:mrn:imo:mmsi:247111000","updates":[
1314 {"$source":"ais-faker.AI","values":[
1315 {"path":"","value":{"mmsi":"247111000"}}]}]}"#,
1316 Some("urn:mrn:signalk:uuid:self"),
1317 )
1318 .expect("a delta");
1319 assert!(mmsi_only.readings.is_empty());
1320 }
1321
1322 #[test]
1323 fn an_ais_targets_call_sign_is_read_off_a_vessel_root_update_too() {
1324 // Confirmed live against a real signalk-server decoding an
1325 // `!AIVDM` type 5 message: the call sign arrives nested under the
1326 // same empty-path vessel-root update as the name, in its own
1327 // fragment -- `{"communication":{"callsignVhf":"IBQY"}}`, not the
1328 // flat `communication.callsignVhf` path a plugin or a data-model
1329 // write would use (see the test below this one). Getting this
1330 // wrong the first time -- modelling only the flat path, never
1331 // checked against a live decode -- was exactly the mistake this
1332 // module's own name handling had already been caught making once.
1333 let delta = parse(
1334 r#"{"context":"vessels.urn:mrn:imo:mmsi:247111000","updates":[
1335 {"$source":"ais-faker.AI","values":[
1336 {"path":"","value":{"communication":{"callsignVhf":"IBQY"}}}]}]}"#,
1337 Some("urn:mrn:signalk:uuid:self"),
1338 )
1339 .expect("a delta");
1340 assert_eq!(delta.readings[0].measurement, Measurement::Callsign("IBQY".to_owned()));
1341 }
1342
1343 #[test]
1344 fn a_vessels_name_is_read_off_the_wire_on_any_context() {
1345 // Same bare `name` path Self and an AIS target both carry --
1346 // measurement_of does not know or care which context a delta is
1347 // for, only the caller does.
1348 let delta = parse(
1349 r#"{"context":"vessels.urn:mrn:imo:mmsi:238123456","updates":[
1350 {"$source":"ais.0","values":[
1351 {"path":"name","value":"Windward"}]}]}"#,
1352 Some("urn:mrn:signalk:uuid:self"),
1353 )
1354 .expect("a delta");
1355 assert_eq!(delta.readings[0].measurement, Measurement::VesselName("Windward".to_owned()));
1356 }
1357
1358 #[test]
1359 fn an_ais_targets_tracking_status_is_read_off_the_wire() {
1360 // sk-ais-status-plugin's own path, confirmed live -- not a Signal
1361 // K specification path.
1362 let delta = parse(
1363 r#"{"context":"vessels.urn:mrn:imo:mmsi:238123456","updates":[
1364 {"$source":"sk-ais-status","values":[
1365 {"path":"sensors.ais.status","value":"confirmed"}]}]}"#,
1366 Some("urn:mrn:signalk:uuid:self"),
1367 )
1368 .expect("a delta");
1369 assert_eq!(
1370 delta.readings[0].measurement,
1371 Measurement::AisTargetStatus(AisTargetStatus::Confirmed)
1372 );
1373 }
1374
1375 #[test]
1376 fn an_unrecognised_ais_status_value_is_not_a_measurement() {
1377 // The plugin only ever sends four values today; a fifth some
1378 // future version might add must be skipped, not guessed at.
1379 let delta = parse(
1380 r#"{"context":"vessels.urn:mrn:imo:mmsi:238123456","updates":[
1381 {"$source":"sk-ais-status","values":[
1382 {"path":"sensors.ais.status","value":"suspicious"}]}]}"#,
1383 Some("urn:mrn:signalk:uuid:self"),
1384 )
1385 .expect("a delta");
1386 assert!(delta.readings.is_empty());
1387 }
1388
1389 #[test]
1390 fn a_closest_approach_alarms_severity_is_read_off_the_wire() {
1391 // signalk-ais-target-prioritizer's own path, confirmed live --
1392 // not a Signal K specification path (notifications.collision.*
1393 // is the schema's own name for this).
1394 let delta = parse(
1395 r#"{"context":"vessels.urn:mrn:imo:mmsi:238123456","updates":[
1396 {"$source":"signalk-ais-target-prioritizer","values":[
1397 {"path":"notifications.navigation.closestApproach","value":
1398 {"state":"alarm","method":["visual","sound"],"message":"CPA ALARM"}}]}]}"#,
1399 Some("urn:mrn:signalk:uuid:self"),
1400 )
1401 .expect("a delta");
1402 assert_eq!(
1403 delta.readings[0].measurement,
1404 Measurement::ClosestApproachAlarm(NotificationSeverity::Alarm)
1405 );
1406 }
1407
1408 #[test]
1409 fn an_unrecognised_closest_approach_severity_is_not_a_measurement() {
1410 let delta = parse(
1411 r#"{"context":"vessels.urn:mrn:imo:mmsi:238123456","updates":[
1412 {"$source":"signalk-ais-target-prioritizer","values":[
1413 {"path":"notifications.navigation.closestApproach","value":
1414 {"state":"nominal"}}]}]}"#,
1415 Some("urn:mrn:signalk:uuid:self"),
1416 )
1417 .expect("a delta");
1418 assert!(delta.readings.is_empty());
1419 }
1420
1421 #[test]
1422 fn notification_severity_orders_least_to_most_severe() {
1423 assert!(NotificationSeverity::Normal < NotificationSeverity::Alert);
1424 assert!(NotificationSeverity::Alert < NotificationSeverity::Warn);
1425 assert!(NotificationSeverity::Warn < NotificationSeverity::Alarm);
1426 assert!(NotificationSeverity::Alarm < NotificationSeverity::Emergency);
1427 }
1428
1429 #[test]
1430 fn apparent_wind_angle_keeps_its_own_sign_convention() {
1431 // Signal K already states "negative to port" for this path -- the
1432 // same convention nav_math::wind::Wind uses, so this is a unit
1433 // conversion only, never a sign flip. A quarter turn to port on
1434 // the wire must come out negative, not wrapped into 0..360 the way
1435 // directionTrue is.
1436 let delta = parse(&one_value("environment.wind.angleApparent", "-1.0471975512"), None)
1437 .expect("a delta");
1438 let Measurement::WindAngleApparent(deg) = delta.readings[0].measurement else {
1439 panic!("not an apparent wind angle: {:?}", delta.readings[0]);
1440 };
1441 assert!((deg - -60.0).abs() < 1e-6, "got {deg}");
1442 }
1443
1444 #[test]
1445 fn apparent_wind_speed_arrives_in_metres_per_second_and_is_read_in_knots() {
1446 let delta = parse(&one_value("environment.wind.speedApparent", "5.144444"), None)
1447 .expect("a delta");
1448 let Measurement::WindSpeedApparent(knots) = delta.readings[0].measurement else {
1449 panic!("not an apparent wind speed");
1450 };
1451 assert!((knots - 10.0).abs() < 1e-4, "got {knots}");
1452 }
1453
1454 #[test]
1455 fn true_wind_direction_is_an_absolute_bearing_not_a_relative_angle() {
1456 // Unlike angleApparent, this is a compass direction -- it wraps
1457 // into 0..360, the same convention every other bearing in this
1458 // workspace uses, not -180..180.
1459 let delta = parse(&one_value("environment.wind.directionTrue", "-0.1745329"), None)
1460 .expect("a delta");
1461 let Measurement::WindDirectionTrue(deg) = delta.readings[0].measurement else {
1462 panic!("not a true wind direction");
1463 };
1464 assert!((deg - 350.0).abs() < 1e-4, "got {deg}");
1465 }
1466
1467 #[test]
1468 fn true_wind_speed_arrives_in_metres_per_second_and_is_read_in_knots() {
1469 let delta =
1470 parse(&one_value("environment.wind.speedTrue", "5.144444"), None).expect("a delta");
1471 let Measurement::WindSpeedTrue(knots) = delta.readings[0].measurement else {
1472 panic!("not a true wind speed");
1473 };
1474 assert!((knots - 10.0).abs() < 1e-4, "got {knots}");
1475 }
1476
1477 #[test]
1478 fn a_draft_object_with_no_maximum_is_not_a_measurement() {
1479 // Only minimum/current/canoe set, or an empty object: none of them
1480 // answer "how deep does this vessel sit at its worst", so none of
1481 // them should be read as if they did. Unrecognised, not an error --
1482 // the delta still parses, it just carries no readings.
1483 let with_current = parse(&one_value("design.draft", r#"{"current":1.5}"#), None)
1484 .expect("a delta");
1485 assert!(with_current.readings.is_empty());
1486
1487 let empty = parse(&one_value("design.draft", "{}"), None).expect("a delta");
1488 assert!(empty.readings.is_empty());
1489 }
1490}