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}