Skip to main content

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}