Skip to main content

navcore_signalk_client/
course.rs

1//! Starting and stopping active route following, on the wire.
2//!
3//! Distinct from [`crate::resources`] (which only ever stores a route,
4//! never sails one) and from [`crate::anchor`] (a plain data-model path
5//! with no server-side logic behind it): `navigation.course.*` is
6//! Signal K's own Course API, a dedicated REST resource with a handler
7//! that resolves a route resource, decides which waypoint is next, and
8//! publishes that back out as `navigation.course.nextPoint`/
9//! `previousPoint` deltas. That handler is why this module exists
10//! rather than one more `anchor`-style bare-value PUT. Confirmed
11//! against the Course API bundled with `signalk-server`
12//! (`dist/api/course`):
13//!
14//! - It lives under `/signalk/v2/api/vessels/self/navigation/course`,
15//!   one full API version ahead of the plain `/v1/api/...` data-model
16//!   paths [`crate::anchor`] PUTs to: the `v1` path answers a bare
17//!   404, the `v2` one a real `CourseInfo` body. Sharing the rest of
18//!   the path with `anchor` (`vessels/self/navigation/...`) does not
19//!   carry over.
20//! - `PUT .../activeRoute` takes the destination body unwrapped
21//!   (`{"href": ..., "pointIndex": ...}` directly, not `{"value": {...}}}`
22//!   the way `anchor`'s plain data-model paths do, since this is a REST
23//!   resource endpoint, not a generic value write), and replies with
24//!   the exact `state`/`statusCode`/`message` shape
25//!   `crate::anchor::read_request_response` already reads, reused here
26//!   rather than duplicated.
27//! - `DELETE .../course` (the bare path, no `/activeRoute` suffix) is
28//!   how the server defines "stop", not a `PUT` of `null`.
29//! - `PUT .../activeRoute/nextPoint`, value `{"value": 1}`, advances
30//!   `pointIndex` by one; the server recomputes `nextPoint`/
31//!   `previousPoint` itself from the route's own geometry (confirmed by
32//!   reading the server's own handler, `dist/api/course`, the
33//!   `activeRoute/:action` route).
34//!
35//! Reading back what is being followed (`nextPoint`, `previousPoint`)
36//! is not this module's job: a client watches the delta stream for
37//! that, the same way [`crate::anchor`]'s own doc explains for
38//! `navigation.anchor.position`. This module provides only the write
39//! half: starting or stopping the follow.
40
41use std::time::Duration;
42
43use crate::access::agent;
44use crate::anchor::read_request_response;
45use crate::{ClientError, Trust};
46
47/// Same figure [`crate::resources::TIMEOUT`]/[`crate::anchor::Client`]'s
48/// own use, for the same reason: these are calls on a boat's own LAN.
49const TIMEOUT: Duration = Duration::from_secs(10);
50
51/// A client for starting or stopping active route following on one
52/// server.
53///
54/// Built from what [`crate::stream::Connection`] and
55/// [`crate::access::request_access`] already needed, the same shape
56/// [`crate::anchor::Client`]/[`crate::resources::Client`] already take
57/// for the identical reason.
58pub struct Client {
59    http_base: String,
60    trust: Trust,
61    token: Option<String>,
62}
63
64impl Client {
65    /// A client for the server at `http_base`, using `trust` to decide
66    /// which server may answer and `token` (once one exists) to
67    /// authenticate the write.
68    #[must_use]
69    pub fn new(http_base: String, trust: Trust, token: Option<String>) -> Self {
70        Self { http_base, trust, token }
71    }
72
73    /// Starts following `route_href` (a route resource's own `href`, the
74    /// same string [`crate::resources::Client::publish_route`] hands
75    /// back once a route exists to point at) from its first point.
76    ///
77    /// Neither a starting point index nor sailing the route in reverse
78    /// is exposed here, though both are real fields the Course API
79    /// accepts: this covers following a route from its start, the only
80    /// case this crate currently needs.
81    ///
82    /// # Errors
83    ///
84    /// If there is no token -- a write always needs one -- if the server
85    /// cannot be reached, if `route_href` names a route the server
86    /// cannot resolve, or if it refuses the write for any other reason.
87    pub fn set_active_route(&self, route_href: &str) -> Result<(), ClientError> {
88        let Some(token) = &self.token else {
89            return Err(ClientError::Http(
90                "no token: starting to follow a route needs one, the same as any other write".to_owned(),
91            ));
92        };
93
94        let mut response = agent(&self.trust)?
95            .put(format!("{}/signalk/v2/api/vessels/self/navigation/course/activeRoute", self.http_base))
96            .header("Authorization", format!("Bearer {token}"))
97            .config()
98            .timeout_global(Some(TIMEOUT))
99            .http_status_as_error(false)
100            .build()
101            .send_json(serde_json::json!({ "href": route_href }))
102            .map_err(|error| ClientError::Http(error.to_string()))?;
103
104        let answer: serde_json::Value = response
105            .body_mut()
106            .read_json()
107            .map_err(|error| ClientError::Http(error.to_string()))?;
108        read_request_response(&answer)
109    }
110
111    /// Advances the active route to its own next point via `PUT
112    /// .../activeRoute/nextPoint` with `{"value": 1}` -- one step
113    /// forward, the only direction auto-advance on arrival needs. The
114    /// server recomputes both `nextPoint` and `previousPoint` from the
115    /// route's own geometry and the current `pointIndex`; this is why
116    /// [`Self::set_active_route`] hands over an href instead of a raw
117    /// position -- `pointIndex`, `reverse` and the route's own point
118    /// count stay in whatever agreement the server keeps, rather than
119    /// this crate walking the route client-side.
120    ///
121    /// # Errors
122    ///
123    /// The same as [`Self::set_active_route`], plus a `FAILED` reply if
124    /// no route is currently active at all (the server's own guard, not
125    /// one this client adds).
126    pub fn advance_to_next_point(&self) -> Result<(), ClientError> {
127        let Some(token) = &self.token else {
128            return Err(ClientError::Http(
129                "no token: advancing a followed route needs one, the same as any other write".to_owned(),
130            ));
131        };
132
133        let mut response = agent(&self.trust)?
134            .put(format!(
135                "{}/signalk/v2/api/vessels/self/navigation/course/activeRoute/nextPoint",
136                self.http_base
137            ))
138            .header("Authorization", format!("Bearer {token}"))
139            .config()
140            .timeout_global(Some(TIMEOUT))
141            .http_status_as_error(false)
142            .build()
143            .send_json(serde_json::json!({ "value": 1 }))
144            .map_err(|error| ClientError::Http(error.to_string()))?;
145
146        let answer: serde_json::Value = response
147            .body_mut()
148            .read_json()
149            .map_err(|error| ClientError::Http(error.to_string()))?;
150        read_request_response(&answer)
151    }
152
153    /// Stops following whatever route or destination is currently
154    /// active, if any: a `DELETE` of the course itself, which is how
155    /// the Course API defines "stop" -- not a `PUT` of a null value,
156    /// the way clearing the anchor watch is.
157    ///
158    /// # Errors
159    ///
160    /// The same as [`Self::set_active_route`], minus a route to resolve.
161    pub fn clear_active_route(&self) -> Result<(), ClientError> {
162        let Some(token) = &self.token else {
163            return Err(ClientError::Http(
164                "no token: stopping a followed route needs one, the same as any other write".to_owned(),
165            ));
166        };
167
168        let mut response = agent(&self.trust)?
169            .delete(format!("{}/signalk/v2/api/vessels/self/navigation/course", self.http_base))
170            .header("Authorization", format!("Bearer {token}"))
171            .config()
172            .timeout_global(Some(TIMEOUT))
173            .http_status_as_error(false)
174            .build()
175            .call()
176            .map_err(|error| ClientError::Http(error.to_string()))?;
177
178        let answer: serde_json::Value = response
179            .body_mut()
180            .read_json()
181            .map_err(|error| ClientError::Http(error.to_string()))?;
182        read_request_response(&answer)
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn starting_to_follow_without_a_token_is_refused_before_any_request_is_made() {
192        let client = Client::new("https://example.invalid".to_owned(), Trust::plaintext(), None);
193        let error = client.set_active_route("/resources/routes/x").unwrap_err();
194        assert!(matches!(error, ClientError::Http(ref message) if message.contains("no token")));
195    }
196
197    #[test]
198    fn stopping_without_a_token_is_refused_before_any_request_is_made() {
199        let client = Client::new("https://example.invalid".to_owned(), Trust::plaintext(), None);
200        let error = client.clear_active_route().unwrap_err();
201        assert!(matches!(error, ClientError::Http(ref message) if message.contains("no token")));
202    }
203
204    #[test]
205    fn advancing_without_a_token_is_refused_before_any_request_is_made() {
206        let client = Client::new("https://example.invalid".to_owned(), Trust::plaintext(), None);
207        let error = client.advance_to_next_point().unwrap_err();
208        assert!(matches!(error, ClientError::Http(ref message) if message.contains("no token")));
209    }
210}