Skip to main content

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}