Skip to main content

ados_protocol/
rest.rs

1//! REST client and shared wire types for the agent HTTP API (Contract D, port
2//! 8080).
3//!
4//! The native front (`ados-control`) serves the API on `:8080`; this is the
5//! client side used by the Rust TUI (and any other Rust REST consumer). Auth is
6//! the `X-ADOS-Key` header, or a dashboard PIN session in
7//! `X-ADOS-Dashboard-Session`; the pairing routes are reachable while unpaired.
8//! The client is blocking (`ureq`, HTTP-only) because its callers are simple
9//! pollers and worker threads off the flight-critical path.
10//!
11//! The client returns raw [`serde_json::Value`] bodies. The [`types`] submodule
12//! adds typed serde structs for the read-projection shapes that the native
13//! route handlers project out of subsystem sockets and sidecars. Defining each
14//! shape once here keeps the data contract in the shared protocol crate, so a
15//! handler does not re-invent an ad-hoc struct per route. Every type carries an
16//! open `extra` map (via `#[serde(flatten)]`) and `#[serde(default)]` fields, so
17//! a new field on the producer side round-trips through a consumer that does not
18//! yet know about it.
19
20use std::io::Read;
21use std::sync::{Arc, PoisonError, RwLock};
22use std::time::Duration;
23
24use serde_json::Value;
25use thiserror::Error;
26
27pub use types::*;
28
29/// Default base URL: the agent's local HTTP API. Matches `cli/main.py`'s
30/// `API_BASE`.
31pub const DEFAULT_BASE: &str = "http://localhost:8080";
32
33/// Default per-request timeout, matching the Python CLI's httpx client.
34pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(8);
35
36/// Connect timeout for a long-lived stream (server-sent events). The stream
37/// itself has no read timeout: an idle log stream is not a failure.
38pub const STREAM_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
39
40#[derive(Debug, Error)]
41pub enum RestError {
42    /// A non-2xx answer, with its JSON body when the server sent one.
43    #[error("http {code}{suffix}", suffix = status_detail_suffix(.body.as_ref()))]
44    Status { code: u16, body: Option<Value> },
45    #[error("transport error: {0}")]
46    Transport(String),
47    #[error("response was not valid json: {0}")]
48    Json(String),
49}
50
51impl RestError {
52    /// The HTTP status code, when the server answered.
53    pub fn code(&self) -> Option<u16> {
54        match self {
55            RestError::Status { code, .. } => Some(*code),
56            _ => None,
57        }
58    }
59
60    /// The JSON body of a non-2xx answer, when there was one.
61    pub fn body(&self) -> Option<&Value> {
62        match self {
63            RestError::Status { body, .. } => body.as_ref(),
64            _ => None,
65        }
66    }
67
68    /// The human detail of a non-2xx answer: the body's `detail` string, else
69    /// `message`, else `error`. Empty when the body carries none of them.
70    pub fn detail(&self) -> String {
71        self.body().map(status_detail).unwrap_or_default()
72    }
73}
74
75/// The human detail of an error body. Flat bodies carry `detail`, `message` or
76/// `error` as a string; nested ones carry an object under `detail` and/or
77/// `error` (`{"detail": {"error": {"code", "message"}}}`). The first string
78/// found among `detail`, `message`, `error`, each looked up directly and then
79/// as `<k>.message` and `<k>.error.message`, wins; empty when there is none.
80pub fn status_detail(body: &Value) -> String {
81    const PATHS: [&[&str]; 9] = [
82        &["detail"],
83        &["message"],
84        &["error"],
85        &["detail", "message"],
86        &["detail", "error", "message"],
87        &["detail", "error"],
88        &["error", "message"],
89        &["detail", "error", "code"],
90        &["error", "code"],
91    ];
92    PATHS
93        .iter()
94        .find_map(|p| {
95            p.iter()
96                .try_fold(body, |v, k| v.get(*k))
97                .and_then(Value::as_str)
98        })
99        .unwrap_or_default()
100        .to_string()
101}
102
103fn status_detail_suffix(body: Option<&Value>) -> String {
104    match body.map(status_detail) {
105        Some(d) if !d.is_empty() => format!(": {d}"),
106        _ => String::new(),
107    }
108}
109
110/// The credentials a [`RestClient`] sends. Shared by every clone of a client,
111/// so setting a key or session on one applies to all of them.
112#[derive(Debug, Clone, Default)]
113pub struct Auth {
114    /// Pairing key, sent as `X-ADOS-Key`.
115    pub api_key: Option<String>,
116    /// Dashboard PIN session, sent as `X-ADOS-Dashboard-Session`.
117    pub session: Option<String>,
118}
119
120/// A blocking REST client for the agent HTTP API. Cheap to clone; clones share
121/// one [`Auth`].
122#[derive(Clone)]
123pub struct RestClient {
124    base: String,
125    auth: Arc<RwLock<Auth>>,
126    agent: ureq::Agent,
127    stream_agent: ureq::Agent,
128}
129
130impl RestClient {
131    /// Build a client for the given base URL (no trailing slash).
132    pub fn new(base: impl Into<String>) -> Self {
133        let agent = ureq::AgentBuilder::new().timeout(DEFAULT_TIMEOUT).build();
134        let stream_agent = ureq::AgentBuilder::new()
135            .timeout_connect(STREAM_CONNECT_TIMEOUT)
136            .build();
137        Self {
138            base: base.into().trim_end_matches('/').to_string(),
139            auth: Arc::default(),
140            agent,
141            stream_agent,
142        }
143    }
144
145    /// Client for the default local agent (`http://localhost:8080`).
146    pub fn local() -> Self {
147        Self::new(DEFAULT_BASE)
148    }
149
150    /// Attach the pairing key sent as `X-ADOS-Key` on every request.
151    pub fn with_api_key(self, key: impl Into<String>) -> Self {
152        self.set_api_key(Some(key.into()));
153        self
154    }
155
156    /// Set or clear the pairing key on this client and every clone of it.
157    pub fn set_api_key(&self, key: Option<String>) {
158        self.auth
159            .write()
160            .unwrap_or_else(PoisonError::into_inner)
161            .api_key = key;
162    }
163
164    /// Set or clear the dashboard PIN session on this client and every clone.
165    pub fn set_session(&self, session: Option<String>) {
166        self.auth
167            .write()
168            .unwrap_or_else(PoisonError::into_inner)
169            .session = session;
170    }
171
172    /// The credentials currently attached.
173    pub fn auth(&self) -> Auth {
174        self.auth
175            .read()
176            .unwrap_or_else(PoisonError::into_inner)
177            .clone()
178    }
179
180    /// The base URL, e.g. `http://localhost:8080`.
181    pub fn base(&self) -> &str {
182        &self.base
183    }
184
185    fn request(&self, agent: &ureq::Agent, method: &str, path: &str) -> ureq::Request {
186        let url = format!("{}{}", self.base, path);
187        let mut req = agent.request(method, &url);
188        let auth = self.auth();
189        if let Some(key) = auth.api_key.as_deref() {
190            req = req.set("X-ADOS-Key", key);
191        }
192        if let Some(session) = auth.session.as_deref() {
193            req = req.set("X-ADOS-Dashboard-Session", session);
194        }
195        req
196    }
197
198    /// GET `path` (e.g. `/api/status`) and parse the JSON body.
199    pub fn get_json(&self, path: &str) -> Result<Value, RestError> {
200        self.send_json("GET", path, None, DEFAULT_TIMEOUT)
201    }
202
203    /// GET `path` with its own request timeout (a slow route such as a Wi-Fi
204    /// scan).
205    pub fn get_json_timeout(&self, path: &str, timeout: Duration) -> Result<Value, RestError> {
206        self.send_json("GET", path, None, timeout)
207    }
208
209    /// Send `method path` with an optional JSON body and parse the JSON reply.
210    /// An empty 2xx body is [`Value::Null`].
211    pub fn send_json(
212        &self,
213        method: &str,
214        path: &str,
215        body: Option<&Value>,
216        timeout: Duration,
217    ) -> Result<Value, RestError> {
218        let req = self.request(&self.agent, method, path).timeout(timeout);
219        let result = match body {
220            Some(b) => req.send_json(b),
221            None => req.call(),
222        };
223        match result {
224            Ok(resp) => {
225                let text = resp
226                    .into_string()
227                    .map_err(|e| RestError::Json(e.to_string()))?;
228                if text.trim().is_empty() {
229                    return Ok(Value::Null);
230                }
231                serde_json::from_str(&text).map_err(|e| RestError::Json(e.to_string()))
232            }
233            Err(ureq::Error::Status(code, resp)) => Err(RestError::Status {
234                code,
235                body: resp.into_json::<Value>().ok(),
236            }),
237            Err(ureq::Error::Transport(t)) => Err(RestError::Transport(t.to_string())),
238        }
239    }
240
241    /// Open a server-sent-events stream on `path`. Only the connect is
242    /// time-bounded; the returned reader blocks until the server writes or
243    /// closes.
244    pub fn open_stream(&self, path: &str) -> Result<Box<dyn Read + Send>, RestError> {
245        let req = self
246            .request(&self.stream_agent, "GET", path)
247            .set("Accept", "text/event-stream");
248        match req.call() {
249            Ok(resp) => Ok(Box::new(resp.into_reader())),
250            Err(ureq::Error::Status(code, resp)) => Err(RestError::Status {
251                code,
252                body: resp.into_json::<Value>().ok(),
253            }),
254            Err(ureq::Error::Transport(t)) => Err(RestError::Transport(t.to_string())),
255        }
256    }
257
258    /// GET `/api/status` — the agent status snapshot the dashboard renders.
259    pub fn status(&self) -> Result<Value, RestError> {
260        self.get_json("/api/status")
261    }
262
263    /// GET `/api/v1/setup/status` — the setup wizard state (read-only).
264    pub fn setup_status(&self) -> Result<Value, RestError> {
265        self.get_json("/api/v1/setup/status")
266    }
267}
268
269/// Typed read-projection shapes for the agent HTTP API.
270///
271/// These structs describe the JSON a native route handler returns when it
272/// projects a subsystem IPC seam or sidecar into the HTTP body the ground
273/// station already consumes. They are deliberately loose: scalar fields are
274/// `Option` with `#[serde(default)]`, and each struct ends in a flattened
275/// `extra` map that captures any field a producer adds ahead of the consumer.
276/// A value therefore round-trips byte-for-content even when the two ends are on
277/// different versions, which is the forward-compatibility contract the rest of
278/// this crate keeps for its open `serde_json::Value` maps.
279pub mod types {
280    use serde::{Deserialize, Serialize};
281    use serde_json::{Map, Value};
282
283    /// An open map carrying any JSON fields a consumer does not yet model. Used
284    /// as the `#[serde(flatten)]` tail of every projection struct so unknown
285    /// fields survive a round-trip instead of being dropped.
286    pub type Extra = Map<String, Value>;
287
288    /// Progress of the parameter-cache priming sweep.
289    #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
290    pub struct ParamProgress {
291        /// Parameters cached so far.
292        #[serde(default)]
293        pub got: u64,
294        /// The FC-advertised total (0 while unknown).
295        #[serde(default)]
296        pub expected: u64,
297    }
298
299    /// The flight-controller parameter list (`GET /api/params`).
300    #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
301    pub struct ParamsResponse {
302        /// Parameter name to value. Parameters are carried as floats on the
303        /// wire; an integer parameter is the exact float of that integer.
304        #[serde(default)]
305        pub params: std::collections::BTreeMap<String, f64>,
306        /// The FC-advertised total when known, else the cached count.
307        #[serde(default)]
308        pub count: u64,
309        /// How many parameters have landed in the cache.
310        #[serde(default)]
311        pub cached: u64,
312        /// Whether the priming sweep is still running.
313        #[serde(default)]
314        pub priming: bool,
315        /// The FC stayed silent past the sweep deadline.
316        #[serde(default)]
317        pub priming_timeout: bool,
318        /// The sweep request itself failed at the link layer.
319        #[serde(default)]
320        pub priming_send_failed: bool,
321        /// Sweep progress, when reported.
322        #[serde(default, skip_serializing_if = "Option::is_none")]
323        pub progress: Option<ParamProgress>,
324        /// Any extra envelope fields.
325        #[serde(flatten)]
326        pub extra: Extra,
327    }
328
329    /// The radio link state projected from the WFB manager's sidecar.
330    ///
331    /// `status` and `active` are the always-present summary; the numeric link
332    /// stats are `Option` because they only exist once a link is up. New
333    /// signals (per-stream stats, antenna diversity, fec) ride the `extra` map.
334    #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
335    pub struct WfbLinkState {
336        /// Coarse link status (e.g. `locked`, `searching`, `down`).
337        #[serde(default)]
338        pub status: String,
339        /// Whether the radio transport is currently running.
340        #[serde(default)]
341        pub active: bool,
342        /// Active WFB channel, when bound.
343        #[serde(default, skip_serializing_if = "Option::is_none")]
344        pub channel: Option<u32>,
345        /// Received signal strength in dBm, when a peer is heard.
346        #[serde(default, skip_serializing_if = "Option::is_none")]
347        pub rssi_dbm: Option<i32>,
348        /// Decoded (FEC + decrypt) received packets per second.
349        #[serde(default, skip_serializing_if = "Option::is_none")]
350        pub valid_rx_packets_per_s: Option<f64>,
351        /// Transmit throughput in bytes per second.
352        #[serde(default, skip_serializing_if = "Option::is_none")]
353        pub tx_bytes_per_s: Option<f64>,
354        /// Any extra link fields the manager surfaces.
355        #[serde(flatten)]
356        pub extra: Extra,
357    }
358
359    /// The mesh state projected from the mesh manager.
360    ///
361    /// The neighbor/route/gateway lists stay as open [`Value`]s because their
362    /// entry shapes evolve with the mesh carrier; modeling them here would
363    /// freeze a contract the producer still moves.
364    #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
365    pub struct MeshState {
366        /// Directly heard mesh neighbors.
367        #[serde(default)]
368        pub neighbors: Vec<Value>,
369        /// Known mesh routes.
370        #[serde(default)]
371        pub routes: Vec<Value>,
372        /// Elected/known cloud gateways.
373        #[serde(default)]
374        pub gateways: Vec<Value>,
375        /// This node's mesh role (e.g. `direct`, `relay`, `receiver`).
376        #[serde(default, skip_serializing_if = "Option::is_none")]
377        pub role: Option<String>,
378        /// Any extra mesh fields.
379        #[serde(flatten)]
380        pub extra: Extra,
381    }
382
383    /// One systemd-managed agent service as the service-list route reports it.
384    #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
385    pub struct ServiceEntry {
386        /// Unit name (e.g. `ados-supervisor`).
387        pub name: String,
388        /// High-level state (e.g. `active`, `failed`, `inactive`).
389        pub state: String,
390        /// Whether the unit is currently active.
391        #[serde(default)]
392        pub active: bool,
393        /// systemd sub-state (e.g. `running`, `exited`, `dead`).
394        #[serde(default, skip_serializing_if = "Option::is_none")]
395        pub sub_state: Option<String>,
396        /// Resident memory in megabytes, when the unit reports it.
397        #[serde(default, skip_serializing_if = "Option::is_none")]
398        pub memory_mb: Option<f64>,
399        /// Any extra per-service fields.
400        #[serde(flatten)]
401        pub extra: Extra,
402    }
403
404    /// The agent service list.
405    #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
406    pub struct ServicesResponse {
407        /// The services, in the order the route returns them.
408        #[serde(default)]
409        pub services: Vec<ServiceEntry>,
410        /// Any extra envelope fields.
411        #[serde(flatten)]
412        pub extra: Extra,
413    }
414
415    /// Whether this agent is enrolled in a fleet.
416    #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
417    pub struct FleetEnrollment {
418        /// Enrollment flag.
419        #[serde(default)]
420        pub enrolled: bool,
421        /// Any extra enrollment fields (fleet id, enrolled-at, owner).
422        #[serde(flatten)]
423        pub extra: Extra,
424    }
425
426    /// The fleet peer list. Peer entries stay open because the peer shape is
427    /// carried by the fleet layer, not frozen here.
428    #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
429    pub struct FleetPeers {
430        /// The known fleet peers.
431        #[serde(default)]
432        pub peers: Vec<Value>,
433        /// Any extra envelope fields.
434        #[serde(flatten)]
435        pub extra: Extra,
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use serde_json::{json, Map};
443    use std::io::{Read, Write};
444    use std::net::TcpListener;
445
446    /// Spawn a one-shot HTTP server that returns `body` with `status`, and
447    /// return the base URL pointing at it.
448    fn one_shot_server(status_line: &'static str, body: &'static str) -> String {
449        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
450        let addr = listener.local_addr().unwrap();
451        std::thread::spawn(move || {
452            if let Ok((mut stream, _)) = listener.accept() {
453                let mut buf = [0u8; 1024];
454                let _ = stream.read(&mut buf); // consume the request line + headers
455                let response = format!(
456                    "HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
457                    body.len()
458                );
459                let _ = stream.write_all(response.as_bytes());
460            }
461        });
462        format!("http://{addr}")
463    }
464
465    #[test]
466    fn get_json_parses_a_200_response() {
467        let base = one_shot_server("200 OK", r#"{"armed":false,"mode":"STABILIZE"}"#);
468        let client = RestClient::new(base);
469        let v = client.status().unwrap();
470        assert_eq!(v["mode"], "STABILIZE");
471        assert_eq!(v["armed"], false);
472    }
473
474    #[test]
475    fn non_2xx_maps_to_status_error() {
476        let base = one_shot_server("401 Unauthorized", r#"{"detail":"missing key"}"#);
477        let client = RestClient::new(base).with_api_key("wrong");
478        match client.status() {
479            Err(e @ RestError::Status { code: 401, .. }) => {
480                assert_eq!(e.detail(), "missing key");
481                assert_eq!(e.to_string(), "http 401: missing key");
482            }
483            other => panic!("expected 401 status error, got {other:?}"),
484        }
485    }
486
487    #[test]
488    fn nested_error_bodies_yield_their_message() {
489        let nested = json!({"detail": {"error": {"code": "E_WIFI_STATUS_UNAVAILABLE", "message": "nmcli missing"}}});
490        assert_eq!(status_detail(&nested), "nmcli missing");
491        let coded = json!({"detail": {"error": {"code": "E_RIG_INVALID"}}});
492        assert_eq!(status_detail(&coded), "E_RIG_INVALID");
493        assert_eq!(status_detail(&json!({"detail": {"message": "m"}})), "m");
494        assert_eq!(status_detail(&json!({"other": 1})), "");
495    }
496
497    #[test]
498    fn an_error_body_rides_the_status_error() {
499        let base = one_shot_server(
500            "409 Conflict",
501            r#"{"error":"E_ARMED","message":"vehicle is armed"}"#,
502        );
503        let client = RestClient::new(base);
504        let err = client
505            .send_json(
506                "PUT",
507                "/api/config",
508                Some(&json!({"key": "a", "value": 1})),
509                DEFAULT_TIMEOUT,
510            )
511            .unwrap_err();
512        assert_eq!(err.code(), Some(409));
513        assert_eq!(err.body().unwrap()["error"], "E_ARMED");
514        // `message` outranks `error` as the human detail.
515        assert_eq!(err.detail(), "vehicle is armed");
516    }
517
518    #[test]
519    fn an_empty_2xx_body_is_null() {
520        let base = one_shot_server("204 No Content", "");
521        let client = RestClient::new(base);
522        let v = client
523            .send_json("DELETE", "/api/v1/network/client", None, DEFAULT_TIMEOUT)
524            .unwrap();
525        assert_eq!(v, Value::Null);
526    }
527
528    #[test]
529    fn transport_error_when_nothing_listens() {
530        // Port 1 is privileged and nothing listens; connect fails fast.
531        let client = RestClient::new("http://127.0.0.1:1");
532        assert!(matches!(client.status(), Err(RestError::Transport(_))));
533    }
534
535    /// Round-trip a value through JSON and assert it is unchanged. Run twice
536    /// over: as the typed struct (Rust equality) and as the re-parsed JSON
537    /// (wire equality), so a dropped or reordered field shows up either way.
538    fn assert_round_trip<T>(value: &T)
539    where
540        T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug,
541    {
542        let wire = serde_json::to_value(value).unwrap();
543        let back: T = serde_json::from_value(wire.clone()).unwrap();
544        assert_eq!(value, &back, "typed value changed across a round-trip");
545        let rewire = serde_json::to_value(&back).unwrap();
546        assert_eq!(wire, rewire, "wire JSON changed across a round-trip");
547    }
548
549    #[test]
550    fn params_response_reads_the_wire_map() {
551        let raw = json!({
552            "params": {"ARMING_CHECK": 1.0, "BATT_CAPACITY": 5200.0},
553            "count": 700,
554            "cached": 2,
555            "priming": true,
556            "priming_timeout": false,
557            "priming_send_failed": false,
558            "progress": {"got": 2, "expected": 700}
559        });
560        let resp: ParamsResponse = serde_json::from_value(raw).unwrap();
561        assert_eq!(resp.params["BATT_CAPACITY"], 5200.0);
562        assert_eq!(
563            resp.progress,
564            Some(ParamProgress {
565                got: 2,
566                expected: 700
567            })
568        );
569        assert!(resp.priming);
570        assert_round_trip(&resp);
571    }
572
573    #[test]
574    fn wfb_link_state_round_trips() {
575        let state = WfbLinkState {
576            status: "locked".into(),
577            active: true,
578            channel: Some(149),
579            rssi_dbm: Some(-51),
580            valid_rx_packets_per_s: Some(598.0),
581            tx_bytes_per_s: Some(750_000.0),
582            extra: Map::new(),
583        };
584        assert_round_trip(&state);
585    }
586
587    #[test]
588    fn mesh_state_round_trips() {
589        let state = MeshState {
590            neighbors: vec![json!({"mac": "aa:bb:cc", "rssi": -60})],
591            routes: vec![json!({"dest": "bat0", "metric": 2})],
592            gateways: vec![],
593            role: Some("relay".into()),
594            extra: Map::new(),
595        };
596        assert_round_trip(&state);
597    }
598
599    #[test]
600    fn services_response_round_trips() {
601        let resp = ServicesResponse {
602            services: vec![ServiceEntry {
603                name: "ados-supervisor".into(),
604                state: "active".into(),
605                active: true,
606                sub_state: Some("running".into()),
607                memory_mb: Some(24.5),
608                extra: Map::new(),
609            }],
610            extra: Map::new(),
611        };
612        assert_round_trip(&resp);
613    }
614
615    #[test]
616    fn fleet_shapes_round_trip() {
617        assert_round_trip(&FleetEnrollment {
618            enrolled: true,
619            extra: Map::new(),
620        });
621        assert_round_trip(&FleetPeers {
622            peers: vec![json!({"id": "local-abc", "online": true})],
623            extra: Map::new(),
624        });
625    }
626
627    #[test]
628    fn unknown_fields_survive_the_flatten_map() {
629        // A producer one version ahead attaches a field the struct does not
630        // model. It must be captured in `extra` and re-emitted unchanged.
631        let raw = json!({
632            "status": "locked",
633            "active": true,
634            "channel": 149,
635            "antenna_diversity": {"best": 1, "count": 4},
636            "fec_recovered": 12
637        });
638        let state: WfbLinkState = serde_json::from_value(raw.clone()).unwrap();
639        assert_eq!(state.extra["fec_recovered"], 12);
640        assert_eq!(state.extra["antenna_diversity"]["best"], 1);
641        // The captured fields ride back out on serialization.
642        let back = serde_json::to_value(&state).unwrap();
643        assert_eq!(back, raw);
644    }
645
646    #[test]
647    fn missing_optional_fields_default_cleanly() {
648        // Only the always-present summary fields are sent; the numeric link
649        // stats are absent. Deserialization must not fail, and the absent
650        // optionals must not be re-emitted (skip_serializing_if).
651        let raw = json!({"status": "down", "active": false});
652        let state: WfbLinkState = serde_json::from_value(raw.clone()).unwrap();
653        assert_eq!(state.channel, None);
654        assert_eq!(state.rssi_dbm, None);
655        let back = serde_json::to_value(&state).unwrap();
656        assert_eq!(back, raw);
657    }
658}