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}