navcore_signalk_client/anchor.rs
1//! Setting the anchor watch, on the wire.
2//!
3//! `navigation.anchor.position` and `navigation.anchor.maxRadius` are
4//! ordinary Signal K data-model paths, not Resources-API items: there
5//! is no list to fetch and no id the server mints. A client watches the
6//! delta stream for their current values, the same way it already
7//! watches `navigation.position`/`design.draft`/wind; this module
8//! provides only the write half, setting or clearing them.
9//!
10//! Confirmed against a `signalk-server` running
11//! `signalk-anchoralarm-plugin` (the ecosystem's reference anchor-alarm
12//! plugin): it registers real `PUT` action handlers for both paths
13//! (`app.registerPutHandler`), so a plain
14//! `PUT /signalk/v1/api/vessels/self/navigation/anchor/{path}` works,
15//! unlike a path nothing has claimed, which the server's generic
16//! handler refuses with `405 PUT not supported`. Setting
17//! `navigation.anchor.position` to `null` also nulls `maxRadius`: the
18//! plugin's own `getAnchorDelta` nulls both together whenever no
19//! position is given. The read side's whole contract is that
20//! "anchored" means `navigation.anchor.position` is currently
21//! non-null, nothing more.
22//!
23//! The reply to one of these `PUT`s is the server's plain
24//! `requestResponse` shape (`state`/`statusCode`/`requestId`/...), not
25//! the Resources API's `ActionResult` (`id`/`message` on success);
26//! [`resources::read_action_response`](crate::resources) is the wrong
27//! reader for it. A captured reply:
28//! `{"state":"COMPLETED","requestId":"...","statusCode":202,...}`.
29
30use std::time::Duration;
31
32use nav_math::Position;
33
34use crate::access::agent;
35use crate::{ClientError, Trust};
36
37/// Same figure [`crate::resources::TIMEOUT`] uses, for the same reason:
38/// these are calls on a boat's own LAN.
39const TIMEOUT: Duration = Duration::from_secs(10);
40
41/// A client for setting one server's own anchor watch.
42///
43/// Built from what [`crate::stream::Connection`] and
44/// [`crate::access::request_access`] already needed, the same shape
45/// [`crate::resources::Client`] already takes for the identical reason.
46pub struct Client {
47 http_base: String,
48 trust: Trust,
49 token: Option<String>,
50}
51
52impl Client {
53 /// A client for the server at `http_base`, using `trust` to decide
54 /// which server may answer and `token` (once one exists) to
55 /// authenticate the write.
56 #[must_use]
57 pub fn new(http_base: String, trust: Trust, token: Option<String>) -> Self {
58 Self { http_base, trust, token }
59 }
60
61 /// Sets the anchor position and swing radius, starting the plugin's
62 /// own drift monitoring once both are known.
63 ///
64 /// Two separate `PUT`s -- there is no combined endpoint -- sent
65 /// radius first. Either order leaves the plugin watching once both
66 /// are set, but sending the position last means the plugin's own
67 /// "radius already known" branch starts monitoring, rather than
68 /// racing whichever call happens to land second.
69 ///
70 /// # Errors
71 ///
72 /// If there is no token -- a write always needs one -- if the server
73 /// cannot be reached, or if it refuses the write.
74 pub fn set_anchor(&self, position: Position, max_radius_m: f64) -> Result<(), ClientError> {
75 self.put("maxRadius", &serde_json::json!(max_radius_m))?;
76 self.put(
77 "position",
78 &serde_json::json!({ "latitude": position.lat_deg, "longitude": position.lon_deg }),
79 )
80 }
81
82 /// Raises the anchor. Clears `navigation.anchor.position`, which the
83 /// plugin answers by nulling `maxRadius` (and everything else it
84 /// derives) along with it -- see this module's own doc -- so
85 /// nothing further needs clearing from here.
86 ///
87 /// # Errors
88 ///
89 /// The same as [`Self::set_anchor`].
90 pub fn clear_anchor(&self) -> Result<(), ClientError> {
91 self.put("position", &serde_json::Value::Null)
92 }
93
94 /// One `PUT vessels/self/navigation/anchor/{path}`, reading the
95 /// server's plain `requestResponse` reply for whether it actually
96 /// took -- see this module's own doc for why that is a different
97 /// shape from the Resources API's `ActionResult`.
98 fn put(&self, path: &str, value: &serde_json::Value) -> Result<(), ClientError> {
99 let Some(token) = &self.token else {
100 return Err(ClientError::Http(
101 "no token: setting the anchor watch needs one, the same as any other write".to_owned(),
102 ));
103 };
104
105 let mut response = agent(&self.trust)?
106 .put(format!("{}/signalk/v1/api/vessels/self/navigation/anchor/{path}", self.http_base))
107 .header("Authorization", format!("Bearer {token}"))
108 .config()
109 .timeout_global(Some(TIMEOUT))
110 .http_status_as_error(false)
111 .build()
112 .send_json(serde_json::json!({ "value": value }))
113 .map_err(|error| ClientError::Http(error.to_string()))?;
114
115 let answer: serde_json::Value = response
116 .body_mut()
117 .read_json()
118 .map_err(|error| ClientError::Http(error.to_string()))?;
119 read_request_response(&answer)
120 }
121}
122
123/// Reads a plain `PUT /signalk/v1/api/...` reply -- `state`/`statusCode`,
124/// not the Resources API's `id`/`message` `ActionResult` shape
125/// [`crate::resources::read_action_response`] reads. `state: "COMPLETED"`
126/// with a `2xx` `statusCode` is success; anything else, including a
127/// `PENDING` state this crate has no way to wait out (no action handler
128/// this client knows of ever answers asynchronously), is reported as the
129/// protocol error it is for this client's purposes.
130///
131/// `pub(crate)` rather than private: [`crate::course`] answers to the
132/// identical reply shape (confirmed live against the same server's own
133/// Course API, whose handlers reply with the exact `Responses.ok`/
134/// `Responses.invalid` shape this reads) and has no reason to read it
135/// differently.
136pub(crate) fn read_request_response(answer: &serde_json::Value) -> Result<(), ClientError> {
137 let state = answer.get("state").and_then(serde_json::Value::as_str);
138 let status_code = answer.get("statusCode").and_then(serde_json::Value::as_u64);
139 match (state, status_code) {
140 (Some("COMPLETED"), Some(200..=299)) => Ok(()),
141 _ => {
142 let message = answer
143 .get("message")
144 .and_then(serde_json::Value::as_str)
145 .map_or_else(|| answer.to_string(), str::to_owned);
146 Err(ClientError::Protocol(message))
147 }
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn a_completed_reply_is_success() {
157 let answer = serde_json::json!({"state": "COMPLETED", "statusCode": 200, "requestId": "x"});
158 assert!(read_request_response(&answer).is_ok());
159 }
160
161 #[test]
162 fn a_completed_reply_with_202_is_still_success() {
163 // The live server answered exactly this shape for both PUTs this
164 // module made against the real plugin.
165 let answer = serde_json::json!({"state": "COMPLETED", "statusCode": 202, "requestId": "x"});
166 assert!(read_request_response(&answer).is_ok());
167 }
168
169 #[test]
170 fn a_refused_put_is_reported_with_its_own_message() {
171 let answer = serde_json::json!({
172 "state": "COMPLETED",
173 "statusCode": 405,
174 "message": "PUT not supported for navigation.anchor.position",
175 });
176 let error = read_request_response(&answer).unwrap_err();
177 assert!(matches!(error, ClientError::Protocol(message) if message.contains("not supported")));
178 }
179
180 #[test]
181 fn a_pending_reply_is_not_treated_as_success() {
182 let answer = serde_json::json!({"state": "PENDING", "statusCode": 202, "requestId": "x"});
183 assert!(read_request_response(&answer).is_err());
184 }
185}