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}