Skip to main content

navcore_signalk/
notification.rs

1//! Raising, and standing down, a Signal K notification, on the wire.
2//!
3//! `notifications.mob` is a real, schema-defined path -- alongside
4//! `fire`, `sinking`, `flooding`, `collision`, `grounding`, `listing`,
5//! `adrift`, `piracy` and `abandon`, the same generic `{method, state,
6//! message}` shape every one of them takes. Unlike `navigation.anchor.*`
7//! (see `signalk-client::anchor`'s own doc), nothing here needs a
8//! registered `PUT` handler: raising a notification is exactly the same
9//! kind of act as any instrument publishing a reading -- confirmed live,
10//! a plain delta sent on an already-open, `self`-subscribed connection is
11//! accepted and echoed straight back, the server even minting its own
12//! `notificationId` for it.
13//!
14//! # Standing an alarm down is a state transition, not a delete
15//!
16//! A Signal K notification has no delete -- `signalk-anchoralarm-plugin`'s
17//! own drag alarm (read directly from its bundled source) clears itself
18//! the identical way: publish a fresh delta on the same path, `state:
19//! "normal"`, rather than retracting anything. [`mob_delta`] follows the
20//! same convention: a client's own MOB handling calls it with
21//! `"emergency"` to raise the alert and `"normal"` to stand it down,
22//! never anything else.
23//!
24//! No path here carries a *position* -- a notification is purely an
25//! alert. Where the mark itself lives is a caller's own decision; see
26//! a client's own MOB handling for why that is deliberately local-first
27//! rather than round-tripped through here at all.
28
29/// The delta text a caller's own `signalk_client::stream::Connection::publish`
30/// sends to raise or stand down `notifications.mob`.
31///
32/// `state` is the caller's own choice -- `"emergency"` to raise it,
33/// `"normal"` to stand it down, one of the schema's own `alarmState`
34/// enum values (`nominal`/`normal`/`alert`/`warn`/`alarm`/`emergency`);
35/// this function does not validate it, the same "wire shape only"
36/// distance this crate keeps everywhere else. `method` is fixed at
37/// `["visual", "sound"]` regardless -- the same value `freeboard-sk`
38/// (Signal K's own reference chartplotter) raises this exact
39/// notification with, confirmed by reading its own bundled source, and
40/// there is no reason for a stood-down alert to ask for less attention
41/// than the raised one did before a mariner already saw and acted on it.
42///
43/// `timestamp_rfc3339` is the caller's own clock, not read here: this
44/// crate deliberately keeps no clock of its own (see its own module doc,
45/// "no clock" -- a recorded stream of deltas has to replay identically,
46/// which a `SystemTime::now()` call buried in here would break).
47#[must_use]
48pub fn mob_delta(source_label: &str, state: &str, message: &str, timestamp_rfc3339: &str) -> String {
49    let payload = serde_json::json!({
50        "context": "vessels.self",
51        "updates": [{
52            "source": { "label": source_label },
53            "timestamp": timestamp_rfc3339,
54            "values": [{
55                "path": "notifications.mob",
56                "value": {
57                    "state": state,
58                    "method": ["visual", "sound"],
59                    "message": message,
60                }
61            }]
62        }]
63    });
64    payload.to_string()
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70
71    #[test]
72    fn the_delta_names_the_standard_mob_notification_path() {
73        let text = mob_delta("client", "emergency", "Person overboard", "2026-08-22T08:16:30.148Z");
74        assert!(text.contains("\"path\":\"notifications.mob\""), "{text}");
75        assert!(text.contains("\"state\":\"emergency\""), "{text}");
76        assert!(text.contains("\"message\":\"Person overboard\""), "{text}");
77        assert!(text.contains("\"context\":\"vessels.self\""), "{text}");
78    }
79
80    #[test]
81    fn standing_it_down_carries_the_normal_state_instead() {
82        let text = mob_delta("client", "normal", "Person overboard \u{2014} cleared", "2026-08-22T08:16:30.148Z");
83        assert!(text.contains("\"state\":\"normal\""), "{text}");
84    }
85
86    #[test]
87    fn the_source_label_is_carried_through() {
88        let text = mob_delta("client", "emergency", "Person overboard", "2026-08-22T08:16:30.148Z");
89        assert!(text.contains("\"label\":\"client\""), "{text}");
90    }
91
92    #[test]
93    fn it_parses_as_the_delta_this_crate_itself_reads() {
94        // Round-trips through this crate's own parser -- not a strict
95        // requirement of the wire format (no client reads this back,
96        // see this module's own doc), but a cheap way to catch this
97        // function producing something malformed by its own crate's
98        // standard.
99        let text = mob_delta("client", "emergency", "Person overboard", "2026-08-22T08:16:30.148Z");
100        assert!(crate::parse(&text, None).is_some(), "{text}");
101    }
102}