Skip to main content

navcore_routes/
signalk.rs

1//! Signal K is the single source of truth for [`crate::Route`]. This
2//! module is not a sync backend but the only backend -- see
3//! `waypoints::signalk`'s own doc for the fuller reasoning, including
4//! the callback-shaped `spawn_*` functions.
5//!
6//! Signal K's Resources API has no conditional write (no `ETag`, no
7//! `If-Match`), so [`save`] always replaces the server's whole copy,
8//! the same "last write wins" a plain `PUT` implies. This crate keeps
9//! no local copy to detect that collision against, the same trade
10//! `waypoints::signalk`'s own doc explains.
11//!
12//! Three operations: [`list`] everything the server holds, [`save`]
13//! one (create or replace -- the caller always supplies the id, see
14//! [`crate::Route::uuid`]'s own doc, so this is always Signal K's
15//! `PUT`, never its id-minting `POST`), and [`delete`] one.
16
17use std::thread;
18
19use signalk_client::ClientError;
20use signalk_client::resources::{Client, Route as WireRoute, RoutePoint as WireRoutePoint};
21
22use nav_math::METRES_PER_NM;
23
24use crate::{Route, Waypoint};
25
26/// What [`list`]/[`save`]/[`delete`] need from a Signal K resources
27/// connection -- exactly [`Client`]'s own three route methods, factored
28/// out so this module's own translation logic can be tested against a
29/// fake, without a real server, the same reason `waypoints::signalk`'s
30/// own `ResourcesBackend` exists.
31pub trait ResourcesBackend {
32    /// See [`Client::list_routes`].
33    ///
34    /// # Errors
35    /// See [`Client::list_routes`].
36    fn list_routes(&self) -> Result<Vec<WireRoute>, ClientError>;
37    /// See [`Client::publish_route`].
38    ///
39    /// # Errors
40    /// See [`Client::publish_route`].
41    fn publish_route(&self, route: &WireRoute) -> Result<String, ClientError>;
42    /// See [`Client::delete_route`].
43    ///
44    /// # Errors
45    /// See [`Client::delete_route`].
46    fn delete_route(&self, uuid: &str) -> Result<(), ClientError>;
47}
48
49impl ResourcesBackend for Client {
50    fn list_routes(&self) -> Result<Vec<WireRoute>, ClientError> {
51        Self::list_routes(self)
52    }
53
54    fn publish_route(&self, route: &WireRoute) -> Result<String, ClientError> {
55        Self::publish_route(self, route)
56    }
57
58    fn delete_route(&self, uuid: &str) -> Result<(), ClientError> {
59        Self::delete_route(self, uuid)
60    }
61}
62
63/// Every route the server currently holds.
64///
65/// # Errors
66///
67/// If the server cannot be reached, or a listed route carries no uuid
68/// at all -- see [`crate::Route::uuid`]'s own doc for why that is a
69/// protocol violation to report rather than an ordinary case to skip
70/// silently.
71pub fn list<B: ResourcesBackend>(backend: &B) -> Result<Vec<Route>, ClientError> {
72    backend
73        .list_routes()?
74        .into_iter()
75        .map(|wire| {
76            let uuid = wire.uuid.ok_or_else(|| ClientError::Protocol("the server listed a route with no uuid".to_owned()))?;
77            Ok(Route {
78                uuid,
79                name: wire.name,
80                description: wire.description,
81                waypoints: wire.points.into_iter().map(|point| Waypoint { position: point.position, name: point.name }).collect(),
82            })
83        })
84        .collect()
85}
86
87/// Creates or replaces `route` at its own id.
88///
89/// # Errors
90///
91/// If there is no token, the server cannot be reached, or it refuses
92/// the write.
93pub fn save<B: ResourcesBackend>(backend: &B, route: &Route) -> Result<(), ClientError> {
94    let wire = WireRoute {
95        uuid: Some(route.uuid.clone()),
96        name: route.name.clone(),
97        description: route.description.clone(),
98        // Computed fresh from the same waypoints in this request. `list`
99        // above never reads a wire route's own `distance_m`, recomputing
100        // it from `waypoints` every time instead -- but other Signal K
101        // clients only have the resource to go on, and freeboard-sk
102        // shows a route with no length at all when this field is `None`.
103        distance_m: Some(route.distance_nm() * METRES_PER_NM),
104        points: route
105            .waypoints
106            .iter()
107            .map(|waypoint| WireRoutePoint { position: waypoint.position, name: waypoint.name.clone() })
108            .collect(),
109    };
110    backend.publish_route(&wire).map(|_id| ())
111}
112
113/// Deletes a route by id.
114///
115/// # Errors
116///
117/// If there is no token, the server cannot be reached, or it refuses
118/// the delete.
119pub fn delete<B: ResourcesBackend>(backend: &B, uuid: &str) -> Result<(), ClientError> {
120    backend.delete_route(uuid)
121}
122
123/// Runs [`list`] on a thread of its own, calling `on_done` with the
124/// result once it has finished. See this module's own doc, and
125/// `waypoints::signalk`'s, for the callback shape.
126pub fn spawn_list(client: Client, on_done: impl FnOnce(Result<Vec<Route>, ClientError>) + Send + 'static) {
127    thread::spawn(move || on_done(list(&client)));
128}
129
130/// [`spawn_list`]'s own doc, [`save`] instead of [`list`].
131pub fn spawn_save(client: Client, route: Route, on_done: impl FnOnce(Result<(), ClientError>) + Send + 'static) {
132    thread::spawn(move || on_done(save(&client, &route)));
133}
134
135/// [`spawn_list`]'s own doc, [`delete`] instead of [`list`].
136pub fn spawn_delete(client: Client, uuid: String, on_done: impl FnOnce(Result<(), ClientError>) + Send + 'static) {
137    thread::spawn(move || on_done(delete(&client, &uuid)));
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use nav_math::Position;
144    use std::cell::RefCell;
145
146    /// A `ResourcesBackend` this module's own tests can script, standing
147    /// in for a real server the same way `waypoints::signalk`'s own
148    /// tests never need one either.
149    #[derive(Default)]
150    struct FakeBackend {
151        listed: Vec<WireRoute>,
152        publish_result: RefCell<Option<Result<String, ClientError>>>,
153        delete_result: RefCell<Option<Result<(), ClientError>>>,
154        published: RefCell<Vec<WireRoute>>,
155        deleted: RefCell<Vec<String>>,
156    }
157
158    impl ResourcesBackend for FakeBackend {
159        fn list_routes(&self) -> Result<Vec<WireRoute>, ClientError> {
160            Ok(self.listed.clone())
161        }
162
163        fn publish_route(&self, route: &WireRoute) -> Result<String, ClientError> {
164            self.published.borrow_mut().push(route.clone());
165            self.publish_result.borrow_mut().take().expect("a scripted publish result")
166        }
167
168        fn delete_route(&self, uuid: &str) -> Result<(), ClientError> {
169            self.deleted.borrow_mut().push(uuid.to_owned());
170            self.delete_result.borrow_mut().take().expect("a scripted delete result")
171        }
172    }
173
174    fn wire_route(uuid: &str, name: &str) -> WireRoute {
175        WireRoute {
176            uuid: Some(uuid.to_owned()),
177            name: Some(name.to_owned()),
178            description: None,
179            distance_m: None,
180            points: vec![
181                WireRoutePoint { position: Position::new(45.5, 13.5), name: Some("Start".to_owned()) },
182                WireRoutePoint { position: Position::new(45.6, 13.6), name: None },
183            ],
184        }
185    }
186
187    #[test]
188    fn list_translates_every_wire_route_and_its_points() {
189        let backend = FakeBackend { listed: vec![wire_route("a", "Piran to Koper")], ..Default::default() };
190        let routes = list(&backend).unwrap();
191        assert_eq!(routes.len(), 1);
192        assert_eq!(routes[0].uuid, "a");
193        assert_eq!(routes[0].name.as_deref(), Some("Piran to Koper"));
194        assert_eq!(routes[0].waypoints.len(), 2);
195        assert_eq!(routes[0].waypoints[0].name.as_deref(), Some("Start"));
196        assert_eq!(routes[0].waypoints[1].name, None);
197    }
198
199    #[test]
200    fn a_listed_route_with_no_uuid_is_a_protocol_error() {
201        let backend = FakeBackend {
202            listed: vec![WireRoute { uuid: None, name: None, description: None, distance_m: None, points: vec![] }],
203            ..Default::default()
204        };
205        assert!(matches!(list(&backend), Err(ClientError::Protocol(_))));
206    }
207
208    #[test]
209    fn save_always_writes_with_the_routes_own_id() {
210        let backend = FakeBackend { publish_result: RefCell::new(Some(Ok("a".to_owned()))), ..Default::default() };
211        let route = Route::new("a", vec![Waypoint::at(Position::new(45.5, 13.5))]);
212        save(&backend, &route).unwrap();
213        assert_eq!(backend.published.borrow()[0].uuid.as_deref(), Some("a"));
214    }
215
216    #[test]
217    fn save_sends_the_routes_own_distance_so_other_signal_k_clients_can_show_it() {
218        let backend = FakeBackend { publish_result: RefCell::new(Some(Ok("a".to_owned()))), ..Default::default() };
219        let route = Route::new(
220            "a",
221            vec![
222                Waypoint::at(Position::new(45.0, 13.0)),
223                Waypoint::at(Position::new(45.0, 13.0292)), // ~1 nm due east at this latitude
224            ],
225        );
226        save(&backend, &route).unwrap();
227        let sent = backend.published.borrow()[0].distance_m.expect("a distance was sent");
228        assert!((sent - route.distance_nm() * METRES_PER_NM).abs() < 0.01, "{sent}");
229    }
230
231    #[test]
232    fn save_propagates_a_refusal() {
233        let backend = FakeBackend {
234            publish_result: RefCell::new(Some(Err(ClientError::Protocol("Invalid route".to_owned())))),
235            ..Default::default()
236        };
237        let route = Route::new("a", vec![Waypoint::at(Position::new(45.5, 13.5))]);
238        assert!(save(&backend, &route).is_err());
239    }
240
241    #[test]
242    fn delete_calls_the_backend_with_the_given_uuid() {
243        let backend = FakeBackend { delete_result: RefCell::new(Some(Ok(()))), ..Default::default() };
244        delete(&backend, "a").unwrap();
245        assert_eq!(backend.deleted.borrow()[0], "a");
246    }
247
248    #[test]
249    fn delete_propagates_a_refusal() {
250        let backend = FakeBackend {
251            delete_result: RefCell::new(Some(Err(ClientError::Protocol("nope".to_owned())))),
252            ..Default::default()
253        };
254        assert!(delete(&backend, "a").is_err());
255    }
256}