Skip to main content

ados_protocol/
node_status.rs

1//! Compact node status and identity for the auxiliary radio lane.
2//!
3//! An operator paired only to a ground station sees the drone it relays as a
4//! bare row: no capabilities, no services, no board. Everything the fleet view
5//! wants lives on the drone's own `/api/status`, and the WFB link carries no IP,
6//! so none of it reaches the ground node. This module is the wire shape that
7//! closes that gap, carried on the auxiliary lane's status and identity
8//! channels.
9//!
10//! ## Why a dedicated schema and not the heartbeat payload
11//!
12//! The cloud heartbeat already assembles exactly these facts, but its payload is
13//! a hundred-plus camelCase fields sized for an HTTP POST. One aux frame carries
14//! [`AUX_MAX_PAYLOAD`] = 1200 bytes, and the
15//! lane is shared with MAVLink at telemetry rates. So this is a deliberately
16//! separate, deliberately small projection: the fields a ground operator acts
17//! on, and nothing else.
18//!
19//! Two economies do the work. The encoding is msgpack rather than JSON, matching
20//! the state socket's v2 form. Field names are two or three characters, because
21//! msgpack writes map keys as strings and twenty-odd long names would spend
22//! several hundred bytes on nothing but labels. Every field is documented here,
23//! which is where the readability lives.
24//!
25//! ## Absent means unknown, never zero
26//!
27//! Every optional field is `skip_serializing_if`, so a fact the drone could not
28//! read is ABSENT from the frame rather than sent as a plausible-looking `0` or
29//! `false`. A ground reader shows "unknown", never a fabricated reading.
30//! This is also what keeps the frame small in practice: a
31//! node with no flight controller spends no bytes saying so.
32//!
33//! ## Trimming, not fragmentation
34//!
35//! A snapshot that will not fit is TRIMMED, in a fixed order, until it does. It
36//! is never split across frames.
37//!
38//! Fragmentation was considered and rejected. It would need a reassembly buffer,
39//! a reassembly timeout, and a partial-frame policy on the receiving side, and
40//! on a lossy radio lane one dropped fragment discards the whole snapshot
41//! anyway. Status is periodic and idempotent: losing one costs a second, and the
42//! next is complete. Trimming keeps the receiver a pure function of one datagram
43//! with no cross-frame state to get wrong.
44//!
45//! In practice trimming never fires. A fully-populated snapshot measures a few
46//! hundred bytes against a 1200-byte budget, and a test pins the worst case with
47//! every field at its maximum length. The ladder exists so that a future field,
48//! or an unusually long board name, degrades predictably instead of silently
49//! failing to send.
50//!
51//! ## Bounded by construction
52//!
53//! Every variable-length field is capped at build time by the [`NodeStatus`]
54//! builders and [`NodeIdentity::build`] — strings truncated, the failed-service list
55//! capped at [`MAX_FAILED_NAMES`]. The cap is not a formality: the failed-unit
56//! list is the one field an operator most wants and the one that grows without
57//! bound on a badly broken node, which is exactly when the lane must not be
58//! flooded.
59
60use serde::{Deserialize, Serialize};
61
62use crate::aux_mux::AUX_MAX_PAYLOAD;
63
64/// Schema version for both the status and identity frames.
65///
66/// Bump on any breaking field-shape change. A reader that does not recognise the
67/// version drops the frame rather than misreading it: the two rigs are upgraded
68/// independently, so a mixed-version pair is a normal state, not an error.
69pub const NODE_STATUS_VERSION: u8 = 2;
70
71/// Longest device id carried. Real ids are 12 hex characters, so this is
72/// headroom rather than a limit that bites.
73pub const MAX_DEVICE_ID: usize = 32;
74
75/// Longest human-facing node name carried.
76pub const MAX_NAME: usize = 48;
77
78/// Longest short free-text field (agent version, board name, SoC, firmware
79/// family, camera and video state).
80pub const MAX_SHORT: usize = 32;
81
82/// Most failed-unit names carried in one snapshot.
83///
84/// A healthy node sends none. A node with more failures than this reports the
85/// first few by name and the true total in [`NodeStatus::sf`], so the
86/// count stays honest even when the list is clipped.
87pub const MAX_FAILED_NAMES: usize = 6;
88
89/// Longest single failed-unit name carried.
90pub const MAX_UNIT_NAME: usize = 40;
91
92/// Longest plugin-inventory string carried: `pluginId@contractVersion` entries
93/// joined by commas. Capped so a node with many plugins degrades predictably
94/// instead of overflowing the frame; a real node runs a handful.
95pub const MAX_PLUGIN_LIST: usize = 240;
96
97/// Truncate `s` to at most `max` characters, respecting character boundaries so
98/// a multi-byte name never becomes invalid UTF-8.
99fn clip(s: &str, max: usize) -> String {
100    if s.chars().count() <= max {
101        return s.to_string();
102    }
103    s.chars().take(max).collect()
104}
105
106/// Clip and normalise an optional string, mapping empty to `None` so a
107/// blank source reads as unknown rather than as a present-but-empty value.
108fn clip_opt(s: Option<&str>, max: usize) -> Option<String> {
109    let v = s?.trim();
110    if v.is_empty() {
111        return None;
112    }
113    Some(clip(v, max))
114}
115
116/// Format an installed-plugin inventory as `id@ver` entries joined by commas,
117/// sorted by id so the string is stable regardless of install order. `None`
118/// when the inventory is empty. Plugin ids are reverse-DNS and carry no `@`, so
119/// the last `@` always separates id from version on decode.
120pub fn plugin_list_string(plugins: &[(String, u16)]) -> Option<String> {
121    if plugins.is_empty() {
122        return None;
123    }
124    let mut entries: Vec<String> = plugins.iter().map(|(id, v)| format!("{id}@{v}")).collect();
125    entries.sort();
126    Some(entries.join(","))
127}
128
129/// Parse a plugin-inventory string produced by [`plugin_list_string`] back into
130/// `(id, contractVersion)` pairs. A malformed entry is skipped rather than
131/// failing the whole parse, so one bad token never blinds a version check.
132pub fn parse_plugin_list(s: &str) -> Vec<(String, u16)> {
133    s.split(',')
134        .filter_map(|tok| {
135            let (id, ver) = tok.trim().rsplit_once('@')?;
136            let ver: u16 = ver.trim().parse().ok()?;
137            let id = id.trim();
138            if id.is_empty() {
139                return None;
140            }
141            Some((id.to_string(), ver))
142        })
143        .collect()
144}
145
146/// A drone's identity, sent less often than status because it does not change.
147///
148/// This is the frame that lets a ground station answer "who is my peer" with the
149/// node's real identity. The presence beacon carries a device id, but the beacon
150/// is a fixed 68-byte HMAC-signed frame with its identity field already full, so
151/// there is no room in it for a name or an agent version.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct NodeIdentity {
154    /// Schema version ([`NODE_STATUS_VERSION`]).
155    pub v: u8,
156    /// The node's persistent device id, the key everything else correlates on.
157    pub id: String,
158    /// Human-facing node name, when the node has one configured.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub nm: Option<String>,
161    /// The node's profile (`drone`, `ground-station`, ...), when known.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub pr: Option<String>,
164    /// Agent version string.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub ver: Option<String>,
167    /// The highest plugin contract version any installed plugin declares, a
168    /// coarse node-wide signal a peer can glance at. The authoritative per-plugin
169    /// detail is [`Self::pl`]. Absent when no plugin is installed.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub pv: Option<u16>,
172    /// The installed plugin inventory as a comma-joined list of
173    /// `pluginId@contractVersion`, clipped to [`MAX_PLUGIN_LIST`]. Lets a peer
174    /// compare per-plugin contract versions and detect an AD05-class wire
175    /// disagreement instead of silently misreading a field.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub pl: Option<String>,
178}
179
180impl NodeIdentity {
181    /// Build a bounded identity frame. Every field is clipped here, so no caller
182    /// can put an unbounded string on the lane.
183    pub fn build(
184        device_id: &str,
185        name: Option<&str>,
186        profile: Option<&str>,
187        version: Option<&str>,
188        plugins: &[(String, u16)],
189    ) -> Self {
190        let pl = plugin_list_string(plugins);
191        Self {
192            v: NODE_STATUS_VERSION,
193            id: clip(device_id.trim(), MAX_DEVICE_ID),
194            nm: clip_opt(name, MAX_NAME),
195            pr: clip_opt(profile, MAX_SHORT),
196            ver: clip_opt(version, MAX_SHORT),
197            pv: plugins.iter().map(|(_, v)| *v).max(),
198            pl: clip_opt(pl.as_deref(), MAX_PLUGIN_LIST),
199        }
200    }
201
202    /// Encode to msgpack. `None` when the frame would exceed one aux payload,
203    /// which the field caps make unreachable; it is a guard, not a path.
204    pub fn encode(&self) -> Option<Vec<u8>> {
205        let bytes = rmp_serde::to_vec_named(self).ok()?;
206        (bytes.len() <= AUX_MAX_PAYLOAD).then_some(bytes)
207    }
208
209    /// Decode a msgpack identity frame, rejecting an unrecognised schema version
210    /// and an empty device id (an id-less identity correlates to nothing and is
211    /// worse than no frame at all).
212    pub fn decode(bytes: &[u8]) -> Option<Self> {
213        let me: Self = rmp_serde::from_slice(bytes).ok()?;
214        if me.v != NODE_STATUS_VERSION || me.id.trim().is_empty() {
215            return None;
216        }
217        Some(me)
218    }
219}
220
221/// A compact projection of a node's status, sized for one aux frame.
222///
223/// Field names are short because msgpack writes map keys verbatim; each is
224/// documented, and the `with_*` builders are the only way to put external data
225/// into one, so the bounds cannot be bypassed.
226#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
227pub struct NodeStatus {
228    /// Schema version ([`NODE_STATUS_VERSION`]).
229    pub v: u8,
230    /// The device id this snapshot describes, so a receiver can key it without
231    /// depending on an identity frame having arrived first.
232    pub id: String,
233    /// Monotonic sequence number. Lets a receiver spot loss and ignore a
234    /// datagram that arrives out of order, which UDP permits even over loopback.
235    pub sq: u32,
236    /// Agent uptime in seconds.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub up: Option<u32>,
239    /// Agent version string.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub ver: Option<String>,
242
243    // ── board ────────────────────────────────────────────────────────────
244    /// Board name (`rock-5c-lite`, `rpi4b`, ...).
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub bn: Option<String>,
247    /// Board SoC.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub bs: Option<String>,
250    /// Board capability tier.
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub bt: Option<u8>,
253
254    // ── flight controller ────────────────────────────────────────────────
255    /// The honest connected-or-reachable verdict: true for a live MAVLink
256    /// heartbeat, and also true for a healthy MSP flight controller
257    /// (Betaflight/iNav), which never emits a MAVLink heartbeat but is
258    /// reachable and drivable over the byte-transparent proxy. False only
259    /// when no flight controller is evidenced at all.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub fc: Option<bool>,
262    /// Whether MAVLink heartbeats are actually arriving. Distinct from [`Self::fc`]
263    /// on purpose: an MSP flight controller is reachable but never sends a MAVLink
264    /// heartbeat, and a serial port can be open with nothing behind it. Collapsing
265    /// the two would report a silent link as a live one.
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub fa: Option<bool>,
268    /// Firmware family from the USB descriptor (`betaflight`, `inav`).
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub fv: Option<String>,
271    /// Canonical firmware family (`ardupilot`, `px4`, `betaflight`, `inav`).
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub ff: Option<String>,
274
275    // ── services ─────────────────────────────────────────────────────────
276    /// Units in `running`.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub sr: Option<u16>,
279    /// Units failed. This is the TRUE total even when [`Self::sn`] is clipped.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub sf: Option<u16>,
282    /// Units in any other state (dead, exited, inactive).
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub so: Option<u16>,
285    /// Names of failed units, capped at [`MAX_FAILED_NAMES`]. Which service
286    /// failed is the single most actionable fact a ground operator can have, so
287    /// it is worth the bytes; the cap is what keeps it safe.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub sn: Option<Vec<String>>,
290
291    // ── resources ────────────────────────────────────────────────────────
292    /// CPU percent.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub cp: Option<f32>,
295    /// Memory used percent.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub mp: Option<f32>,
298    /// Root filesystem used percent.
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub dp: Option<f32>,
301    /// SoC temperature in Celsius.
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub tc: Option<f32>,
304
305    // ── payload ──────────────────────────────────────────────────────────
306    /// Camera state (`ready`, `recovering`, ...).
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub cs: Option<String>,
309    /// Video pipeline state.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub vs: Option<String>,
312    /// How many video legs the node is serving.
313    ///
314    /// A ground station relays a drone it cannot otherwise interrogate — the
315    /// radio carries no IP — so without this it cannot tell a single-camera
316    /// drone from a pod shipping two concurrent legs, and any surface that
317    /// needs to know has to assume. Absent means the drone did not say, which
318    /// is deliberately distinct from a reported `1`.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub vn: Option<u8>,
321}
322
323/// The fields a snapshot sheds, lowest value first, when it will not fit.
324///
325/// Order is deliberate. Failed-unit NAMES go first because the failed COUNT
326/// survives them and carries most of the signal. Then the payload states, then
327/// the board descriptors, then resource gauges, then firmware detail. The core
328/// that never sheds is the version, device id, sequence, and the two flight
329/// controller booleans: identity plus whether the aircraft is talking.
330const TRIM_LADDER: &[fn(&mut NodeStatus)] = &[
331    |s| s.sn = None,
332    |s| {
333        s.cs = None;
334        s.vs = None;
335        s.vn = None;
336    },
337    |s| {
338        s.bs = None;
339        s.bn = None;
340        s.bt = None;
341    },
342    |s| {
343        s.tc = None;
344        s.dp = None;
345        s.mp = None;
346        s.cp = None;
347    },
348    |s| {
349        s.fv = None;
350        s.ff = None;
351    },
352    |s| {
353        s.ver = None;
354        s.up = None;
355        s.sr = None;
356        s.so = None;
357    },
358];
359
360impl NodeStatus {
361    /// Start a snapshot with the always-present core. Optional fields are set by
362    /// the caller through the `with_*` helpers, each of which clips its input.
363    pub fn new(device_id: &str, seq: u32) -> Self {
364        Self {
365            v: NODE_STATUS_VERSION,
366            id: clip(device_id.trim(), MAX_DEVICE_ID),
367            sq: seq,
368            ..Default::default()
369        }
370    }
371
372    /// Set the service summary. `failed_names` is clipped to
373    /// [`MAX_FAILED_NAMES`] entries of [`MAX_UNIT_NAME`] characters; `failed`
374    /// stays the true total so a clipped list never understates the fault.
375    pub fn with_services(
376        mut self,
377        running: u16,
378        failed: u16,
379        other: u16,
380        failed_names: &[String],
381    ) -> Self {
382        self.sr = Some(running);
383        self.sf = Some(failed);
384        self.so = Some(other);
385        if !failed_names.is_empty() {
386            self.sn = Some(
387                failed_names
388                    .iter()
389                    .take(MAX_FAILED_NAMES)
390                    .map(|n| clip(n, MAX_UNIT_NAME))
391                    .collect(),
392            );
393        }
394        self
395    }
396
397    /// Set the board descriptors, clipping each string.
398    pub fn with_board(mut self, name: Option<&str>, soc: Option<&str>, tier: Option<u8>) -> Self {
399        self.bn = clip_opt(name, MAX_SHORT);
400        self.bs = clip_opt(soc, MAX_SHORT);
401        self.bt = tier;
402        self
403    }
404
405    /// Set the flight controller block, clipping each string.
406    pub fn with_fc(
407        mut self,
408        connected: Option<bool>,
409        alive: Option<bool>,
410        variant: Option<&str>,
411        firmware: Option<&str>,
412    ) -> Self {
413        self.fc = connected;
414        self.fa = alive;
415        self.fv = clip_opt(variant, MAX_SHORT);
416        self.ff = clip_opt(firmware, MAX_SHORT);
417        self
418    }
419
420    /// Set the resource gauges.
421    pub fn with_resources(
422        mut self,
423        cpu: Option<f32>,
424        mem: Option<f32>,
425        disk: Option<f32>,
426        temp_c: Option<f32>,
427    ) -> Self {
428        self.cp = cpu;
429        self.mp = mem;
430        self.dp = disk;
431        self.tc = temp_c;
432        self
433    }
434
435    /// Set the payload states, clipping each string.
436    ///
437    /// `video_legs` is how many video legs the node serves. `None` when the node
438    /// could not read its own stream list: a ground station reading this must
439    /// show "unknown", never assume one camera.
440    pub fn with_payload(
441        mut self,
442        camera: Option<&str>,
443        video: Option<&str>,
444        video_legs: Option<u8>,
445    ) -> Self {
446        self.cs = clip_opt(camera, MAX_SHORT);
447        self.vs = clip_opt(video, MAX_SHORT);
448        self.vn = video_legs;
449        self
450    }
451
452    /// Set uptime and agent version.
453    pub fn with_agent(mut self, uptime_s: Option<u32>, version: Option<&str>) -> Self {
454        self.up = uptime_s;
455        self.ver = clip_opt(version, MAX_SHORT);
456        self
457    }
458
459    /// Encode to msgpack, shedding fields down `TRIM_LADDER` until the frame
460    /// fits one aux payload.
461    ///
462    /// Returns the bytes and how many ladder steps were applied, so a producer
463    /// can log that it degraded rather than trimming invisibly. `None` only when
464    /// even the fully-trimmed core does not fit, which the caps make
465    /// unreachable; a producer that somehow hits it must send nothing rather
466    /// than a truncated frame, because a truncated msgpack body decodes as
467    /// garbage instead of failing cleanly.
468    pub fn encode(&self) -> Option<(Vec<u8>, usize)> {
469        let mut candidate = self.clone();
470        let mut applied = 0usize;
471        loop {
472            if let Ok(bytes) = rmp_serde::to_vec_named(&candidate) {
473                if bytes.len() <= AUX_MAX_PAYLOAD {
474                    return Some((bytes, applied));
475                }
476            }
477            // Out of ladder: send nothing rather than a frame that cannot fit.
478            let step = TRIM_LADDER.get(applied)?;
479            step(&mut candidate);
480            applied += 1;
481        }
482    }
483
484    /// Decode a msgpack status frame, rejecting an unrecognised schema version
485    /// and an empty device id.
486    pub fn decode(bytes: &[u8]) -> Option<Self> {
487        let me: Self = rmp_serde::from_slice(bytes).ok()?;
488        if me.v != NODE_STATUS_VERSION || me.id.trim().is_empty() {
489            return None;
490        }
491        Some(me)
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    /// A snapshot with every field at its maximum permitted length: the worst
500    /// case the caps allow.
501    fn maximal() -> NodeStatus {
502        let long = "x".repeat(200);
503        let names: Vec<String> = (0..MAX_FAILED_NAMES + 20)
504            .map(|i| format!("{}-{i}", "u".repeat(120)))
505            .collect();
506        NodeStatus::new(&"d".repeat(120), u32::MAX)
507            .with_agent(Some(u32::MAX), Some(&long))
508            .with_board(Some(&long), Some(&long), Some(255))
509            .with_fc(Some(true), Some(true), Some(&long), Some(&long))
510            .with_services(u16::MAX, u16::MAX, u16::MAX, &names)
511            .with_resources(Some(99.9), Some(99.9), Some(99.9), Some(99.9))
512            .with_payload(Some(&long), Some(&long), Some(u8::MAX))
513    }
514
515    #[test]
516    fn a_maximal_snapshot_fits_one_frame_untrimmed() {
517        // The headline budget claim. Every field at its cap must still fit, and
518        // must fit WITHOUT trimming: if this ever needs the ladder, the schema
519        // has outgrown the frame and that is a design decision, not a runtime
520        // surprise.
521        let (bytes, trimmed) = maximal().encode().expect("maximal snapshot encodes");
522        assert_eq!(trimmed, 0, "maximal snapshot should not need trimming");
523        assert!(
524            bytes.len() <= AUX_MAX_PAYLOAD,
525            "maximal snapshot is {} bytes, budget is {AUX_MAX_PAYLOAD}",
526            bytes.len()
527        );
528        // Pin the measured worst case (638 bytes, ~53% of budget). A schema
529        // change that pushes past this ceiling should fail here and be a
530        // conscious decision, not a silent slide toward the cap.
531        assert!(
532            bytes.len() <= 700,
533            "worst-case snapshot grew to {} bytes; budget headroom is shrinking",
534            bytes.len()
535        );
536    }
537
538    #[test]
539    fn build_bounds_every_variable_length_field() {
540        let s = maximal();
541        assert_eq!(s.id.chars().count(), MAX_DEVICE_ID);
542        assert_eq!(s.ver.as_ref().unwrap().chars().count(), MAX_SHORT);
543        assert_eq!(s.bn.as_ref().unwrap().chars().count(), MAX_SHORT);
544        assert_eq!(s.ff.as_ref().unwrap().chars().count(), MAX_SHORT);
545        assert_eq!(s.cs.as_ref().unwrap().chars().count(), MAX_SHORT);
546        let names = s.sn.as_ref().unwrap();
547        assert_eq!(names.len(), MAX_FAILED_NAMES);
548        assert!(names.iter().all(|n| n.chars().count() <= MAX_UNIT_NAME));
549    }
550
551    #[test]
552    fn a_clipped_failed_list_keeps_the_true_failed_count() {
553        // The clip must not make a badly broken node look less broken: the list
554        // is capped but the count is not.
555        let names: Vec<String> = (0..50).map(|i| format!("ados-svc-{i}")).collect();
556        let s = NodeStatus::new("abc", 1).with_services(3, 50, 1, &names);
557        assert_eq!(s.sn.as_ref().unwrap().len(), MAX_FAILED_NAMES);
558        assert_eq!(s.sf, Some(50));
559    }
560
561    #[test]
562    fn round_trips_through_msgpack() {
563        let s = NodeStatus::new("abcdef123456", 42)
564            .with_agent(Some(3600), Some("1.2.3"))
565            .with_board(Some("rock-5c-lite"), Some("rk3588s2"), Some(3))
566            .with_fc(
567                Some(true),
568                Some(false),
569                Some("betaflight"),
570                Some("betaflight"),
571            )
572            .with_services(12, 1, 2, &["ados-video".to_string()])
573            .with_resources(Some(12.5), Some(48.0), Some(31.2), Some(52.0))
574            .with_payload(Some("ready"), Some("streaming"), Some(2));
575        let (bytes, trimmed) = s.encode().unwrap();
576        assert_eq!(trimmed, 0);
577        assert_eq!(NodeStatus::decode(&bytes).unwrap(), s);
578    }
579
580    #[test]
581    fn a_realistic_snapshot_is_a_small_fraction_of_the_budget() {
582        // Guards against the schema quietly bloating: a normal drone's snapshot
583        // should leave plenty of headroom, not sit just under the cap.
584        let s = NodeStatus::new("abcdef123456", 7)
585            .with_agent(Some(3600), Some("0.99.241"))
586            .with_board(Some("rock-5c-lite"), Some("rk3588s2"), Some(3))
587            .with_fc(Some(true), Some(true), None, Some("ardupilot"))
588            .with_services(14, 0, 3, &[])
589            .with_resources(Some(12.5), Some(48.0), Some(31.2), Some(52.0))
590            .with_payload(Some("ready"), Some("streaming"), Some(2));
591        let (bytes, _) = s.encode().unwrap();
592        assert!(
593            bytes.len() < AUX_MAX_PAYLOAD / 3,
594            "realistic snapshot grew to {} bytes",
595            bytes.len()
596        );
597    }
598
599    #[test]
600    fn the_video_leg_count_crosses_the_lane_and_unknown_stays_unknown() {
601        // A ground station cannot reach a relayed drone over IP, so this frame
602        // is the only way it learns the drone serves more than one camera.
603        // Reporting two must survive the round trip, and a drone that could not
604        // read its own stream list must arrive as absent — not as a plausible
605        // `1`, which is what made a two-leg pod read as single-camera.
606        let two = NodeStatus::new("abcdef123456", 1).with_payload(None, None, Some(2));
607        let back = NodeStatus::decode(&two.encode().unwrap().0).unwrap();
608        assert_eq!(back.vn, Some(2));
609
610        let unknown = NodeStatus::new("abcdef123456", 1).with_payload(None, None, None);
611        let back = NodeStatus::decode(&unknown.encode().unwrap().0).unwrap();
612        assert_eq!(back.vn, None, "unknown must not decode as a count");
613    }
614
615    #[test]
616    fn absent_fields_cost_nothing_and_stay_absent() {
617        // Honest-unknown must also be the cheap case: a bare snapshot carries
618        // only its core, and decodes back with every optional still None.
619        let bare = NodeStatus::new("abcdef123456", 1);
620        let (bytes, _) = bare.encode().unwrap();
621        assert!(bytes.len() < 64, "bare snapshot is {} bytes", bytes.len());
622        let back = NodeStatus::decode(&bytes).unwrap();
623        assert_eq!(back.cp, None);
624        assert_eq!(back.fc, None);
625        assert_eq!(back.sn, None);
626    }
627
628    #[test]
629    fn the_trim_ladder_sheds_in_order_and_keeps_the_core() {
630        // Drive the ladder directly: each step must drop its own fields and
631        // leave identity plus the flight controller booleans standing.
632        let mut s = maximal();
633        for (i, step) in TRIM_LADDER.iter().enumerate() {
634            step(&mut s);
635            assert_eq!(s.v, NODE_STATUS_VERSION, "step {i} dropped the version");
636            assert!(!s.id.is_empty(), "step {i} dropped the device id");
637            assert_eq!(s.fc, Some(true), "step {i} dropped fc connected");
638            assert_eq!(s.fa, Some(true), "step {i} dropped mavlink alive");
639        }
640        assert!(s.sn.is_none() && s.cs.is_none() && s.bn.is_none() && s.cp.is_none());
641        // The failed COUNT outlives the ladder: it is core signal, not detail.
642        assert_eq!(s.sf, Some(u16::MAX));
643    }
644
645    #[test]
646    fn a_foreign_or_future_version_is_dropped_not_misread() {
647        let mut s = NodeStatus::new("abcdef123456", 1);
648        s.v = NODE_STATUS_VERSION + 1;
649        let bytes = rmp_serde::to_vec_named(&s).unwrap();
650        assert!(NodeStatus::decode(&bytes).is_none());
651        assert!(NodeStatus::decode(b"not msgpack at all").is_none());
652    }
653
654    #[test]
655    fn an_idless_snapshot_or_identity_is_rejected() {
656        // An id-less frame correlates to no peer, so it is worse than nothing.
657        let s = NodeStatus::new("   ", 1);
658        let bytes = rmp_serde::to_vec_named(&s).unwrap();
659        assert!(NodeStatus::decode(&bytes).is_none());
660
661        let idy = NodeIdentity::build("", None, None, None, &[]);
662        let bytes = rmp_serde::to_vec_named(&idy).unwrap();
663        assert!(NodeIdentity::decode(&bytes).is_none());
664    }
665
666    #[test]
667    fn identity_round_trips_and_is_bounded() {
668        let idy = NodeIdentity::build(
669            &"d".repeat(99),
670            Some(&"n".repeat(99)),
671            Some("drone"),
672            Some("0.99.241"),
673            &[],
674        );
675        assert_eq!(idy.id.chars().count(), MAX_DEVICE_ID);
676        assert_eq!(idy.nm.as_ref().unwrap().chars().count(), MAX_NAME);
677        let bytes = idy.encode().expect("encodes");
678        assert!(bytes.len() <= AUX_MAX_PAYLOAD);
679        assert_eq!(NodeIdentity::decode(&bytes).unwrap(), idy);
680    }
681
682    #[test]
683    fn blank_optional_strings_read_as_unknown() {
684        // An empty source string must become absent, not present-and-empty: the
685        // ground side renders absent as "unknown" and "" as a real value.
686        let s = NodeStatus::new("abc", 1)
687            .with_board(Some("  "), None, None)
688            .with_fc(None, None, Some(""), None);
689        assert_eq!(s.bn, None);
690        assert_eq!(s.fv, None);
691    }
692
693    #[test]
694    fn a_multibyte_name_clips_without_breaking_utf8() {
695        // Clipping on characters, not bytes, so a name of multi-byte glyphs
696        // never becomes invalid UTF-8 on the wire.
697        let idy = NodeIdentity::build("abc", Some(&"\u{00e9}".repeat(200)), None, None, &[]);
698        let name = idy.nm.as_ref().unwrap();
699        assert_eq!(name.chars().count(), MAX_NAME);
700        let bytes = idy.encode().unwrap();
701        assert_eq!(NodeIdentity::decode(&bytes).unwrap().nm, idy.nm);
702    }
703
704    #[test]
705    fn identity_carries_and_parses_a_plugin_inventory() {
706        let plugins = vec![
707            ("ados.example.beta".to_string(), 3u16),
708            ("ados.example.alpha".to_string(), 1u16),
709        ];
710        let idy = NodeIdentity::build(
711            "abcdef123456",
712            Some("Drone"),
713            Some("drone"),
714            Some("1.0.0"),
715            &plugins,
716        );
717        assert_eq!(
718            idy.pv,
719            Some(3),
720            "pv is the highest contract version present"
721        );
722        // The list is sorted by id, so it is stable regardless of install order.
723        assert_eq!(
724            idy.pl.as_deref(),
725            Some("ados.example.alpha@1,ados.example.beta@3")
726        );
727        let bytes = idy.encode().expect("encodes");
728        let back = NodeIdentity::decode(&bytes).expect("round-trips");
729        assert_eq!(back, idy);
730        assert_eq!(
731            parse_plugin_list(back.pl.as_deref().unwrap()),
732            vec![
733                ("ados.example.alpha".to_string(), 1),
734                ("ados.example.beta".to_string(), 3)
735            ]
736        );
737    }
738
739    #[test]
740    fn an_identity_from_a_future_schema_is_dropped_not_misread() {
741        // A mixed-version pair across an upgrade is exactly when the two halves
742        // would silently disagree on the plugin wire; decode must reject it loud
743        // rather than misread a reshaped field.
744        let mut raw = NodeIdentity::build("abcdef123456", None, None, None, &[]);
745        raw.v = NODE_STATUS_VERSION + 1;
746        let bytes = rmp_serde::to_vec_named(&raw).unwrap();
747        assert!(NodeIdentity::decode(&bytes).is_none());
748    }
749}