use serde::{Deserialize, Serialize};
use crate::aux_mux::AUX_MAX_PAYLOAD;
pub const NODE_STATUS_VERSION: u8 = 2;
pub const MAX_DEVICE_ID: usize = 32;
pub const MAX_NAME: usize = 48;
pub const MAX_SHORT: usize = 32;
pub const MAX_FAILED_NAMES: usize = 6;
pub const MAX_UNIT_NAME: usize = 40;
pub const MAX_PLUGIN_LIST: usize = 240;
fn clip(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
s.chars().take(max).collect()
}
fn clip_opt(s: Option<&str>, max: usize) -> Option<String> {
let v = s?.trim();
if v.is_empty() {
return None;
}
Some(clip(v, max))
}
pub fn plugin_list_string(plugins: &[(String, u16)]) -> Option<String> {
if plugins.is_empty() {
return None;
}
let mut entries: Vec<String> = plugins.iter().map(|(id, v)| format!("{id}@{v}")).collect();
entries.sort();
Some(entries.join(","))
}
pub fn parse_plugin_list(s: &str) -> Vec<(String, u16)> {
s.split(',')
.filter_map(|tok| {
let (id, ver) = tok.trim().rsplit_once('@')?;
let ver: u16 = ver.trim().parse().ok()?;
let id = id.trim();
if id.is_empty() {
return None;
}
Some((id.to_string(), ver))
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeIdentity {
pub v: u8,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nm: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pr: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ver: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pv: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pl: Option<String>,
}
impl NodeIdentity {
pub fn build(
device_id: &str,
name: Option<&str>,
profile: Option<&str>,
version: Option<&str>,
plugins: &[(String, u16)],
) -> Self {
let pl = plugin_list_string(plugins);
Self {
v: NODE_STATUS_VERSION,
id: clip(device_id.trim(), MAX_DEVICE_ID),
nm: clip_opt(name, MAX_NAME),
pr: clip_opt(profile, MAX_SHORT),
ver: clip_opt(version, MAX_SHORT),
pv: plugins.iter().map(|(_, v)| *v).max(),
pl: clip_opt(pl.as_deref(), MAX_PLUGIN_LIST),
}
}
pub fn encode(&self) -> Option<Vec<u8>> {
let bytes = rmp_serde::to_vec_named(self).ok()?;
(bytes.len() <= AUX_MAX_PAYLOAD).then_some(bytes)
}
pub fn decode(bytes: &[u8]) -> Option<Self> {
let me: Self = rmp_serde::from_slice(bytes).ok()?;
if me.v != NODE_STATUS_VERSION || me.id.trim().is_empty() {
return None;
}
Some(me)
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct NodeStatus {
pub v: u8,
pub id: String,
pub sq: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub up: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ver: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bn: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bs: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bt: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fc: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fa: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fv: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ff: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sr: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sf: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub so: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sn: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cp: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mp: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dp: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tc: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cs: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vs: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vn: Option<u8>,
}
const TRIM_LADDER: &[fn(&mut NodeStatus)] = &[
|s| s.sn = None,
|s| {
s.cs = None;
s.vs = None;
s.vn = None;
},
|s| {
s.bs = None;
s.bn = None;
s.bt = None;
},
|s| {
s.tc = None;
s.dp = None;
s.mp = None;
s.cp = None;
},
|s| {
s.fv = None;
s.ff = None;
},
|s| {
s.ver = None;
s.up = None;
s.sr = None;
s.so = None;
},
];
impl NodeStatus {
pub fn new(device_id: &str, seq: u32) -> Self {
Self {
v: NODE_STATUS_VERSION,
id: clip(device_id.trim(), MAX_DEVICE_ID),
sq: seq,
..Default::default()
}
}
pub fn with_services(
mut self,
running: u16,
failed: u16,
other: u16,
failed_names: &[String],
) -> Self {
self.sr = Some(running);
self.sf = Some(failed);
self.so = Some(other);
if !failed_names.is_empty() {
self.sn = Some(
failed_names
.iter()
.take(MAX_FAILED_NAMES)
.map(|n| clip(n, MAX_UNIT_NAME))
.collect(),
);
}
self
}
pub fn with_board(mut self, name: Option<&str>, soc: Option<&str>, tier: Option<u8>) -> Self {
self.bn = clip_opt(name, MAX_SHORT);
self.bs = clip_opt(soc, MAX_SHORT);
self.bt = tier;
self
}
pub fn with_fc(
mut self,
connected: Option<bool>,
alive: Option<bool>,
variant: Option<&str>,
firmware: Option<&str>,
) -> Self {
self.fc = connected;
self.fa = alive;
self.fv = clip_opt(variant, MAX_SHORT);
self.ff = clip_opt(firmware, MAX_SHORT);
self
}
pub fn with_resources(
mut self,
cpu: Option<f32>,
mem: Option<f32>,
disk: Option<f32>,
temp_c: Option<f32>,
) -> Self {
self.cp = cpu;
self.mp = mem;
self.dp = disk;
self.tc = temp_c;
self
}
pub fn with_payload(
mut self,
camera: Option<&str>,
video: Option<&str>,
video_legs: Option<u8>,
) -> Self {
self.cs = clip_opt(camera, MAX_SHORT);
self.vs = clip_opt(video, MAX_SHORT);
self.vn = video_legs;
self
}
pub fn with_agent(mut self, uptime_s: Option<u32>, version: Option<&str>) -> Self {
self.up = uptime_s;
self.ver = clip_opt(version, MAX_SHORT);
self
}
pub fn encode(&self) -> Option<(Vec<u8>, usize)> {
let mut candidate = self.clone();
let mut applied = 0usize;
loop {
if let Ok(bytes) = rmp_serde::to_vec_named(&candidate) {
if bytes.len() <= AUX_MAX_PAYLOAD {
return Some((bytes, applied));
}
}
let step = TRIM_LADDER.get(applied)?;
step(&mut candidate);
applied += 1;
}
}
pub fn decode(bytes: &[u8]) -> Option<Self> {
let me: Self = rmp_serde::from_slice(bytes).ok()?;
if me.v != NODE_STATUS_VERSION || me.id.trim().is_empty() {
return None;
}
Some(me)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn maximal() -> NodeStatus {
let long = "x".repeat(200);
let names: Vec<String> = (0..MAX_FAILED_NAMES + 20)
.map(|i| format!("{}-{i}", "u".repeat(120)))
.collect();
NodeStatus::new(&"d".repeat(120), u32::MAX)
.with_agent(Some(u32::MAX), Some(&long))
.with_board(Some(&long), Some(&long), Some(255))
.with_fc(Some(true), Some(true), Some(&long), Some(&long))
.with_services(u16::MAX, u16::MAX, u16::MAX, &names)
.with_resources(Some(99.9), Some(99.9), Some(99.9), Some(99.9))
.with_payload(Some(&long), Some(&long), Some(u8::MAX))
}
#[test]
fn a_maximal_snapshot_fits_one_frame_untrimmed() {
let (bytes, trimmed) = maximal().encode().expect("maximal snapshot encodes");
assert_eq!(trimmed, 0, "maximal snapshot should not need trimming");
assert!(
bytes.len() <= AUX_MAX_PAYLOAD,
"maximal snapshot is {} bytes, budget is {AUX_MAX_PAYLOAD}",
bytes.len()
);
assert!(
bytes.len() <= 700,
"worst-case snapshot grew to {} bytes; budget headroom is shrinking",
bytes.len()
);
}
#[test]
fn build_bounds_every_variable_length_field() {
let s = maximal();
assert_eq!(s.id.chars().count(), MAX_DEVICE_ID);
assert_eq!(s.ver.as_ref().unwrap().chars().count(), MAX_SHORT);
assert_eq!(s.bn.as_ref().unwrap().chars().count(), MAX_SHORT);
assert_eq!(s.ff.as_ref().unwrap().chars().count(), MAX_SHORT);
assert_eq!(s.cs.as_ref().unwrap().chars().count(), MAX_SHORT);
let names = s.sn.as_ref().unwrap();
assert_eq!(names.len(), MAX_FAILED_NAMES);
assert!(names.iter().all(|n| n.chars().count() <= MAX_UNIT_NAME));
}
#[test]
fn a_clipped_failed_list_keeps_the_true_failed_count() {
let names: Vec<String> = (0..50).map(|i| format!("ados-svc-{i}")).collect();
let s = NodeStatus::new("abc", 1).with_services(3, 50, 1, &names);
assert_eq!(s.sn.as_ref().unwrap().len(), MAX_FAILED_NAMES);
assert_eq!(s.sf, Some(50));
}
#[test]
fn round_trips_through_msgpack() {
let s = NodeStatus::new("abcdef123456", 42)
.with_agent(Some(3600), Some("1.2.3"))
.with_board(Some("rock-5c-lite"), Some("rk3588s2"), Some(3))
.with_fc(
Some(true),
Some(false),
Some("betaflight"),
Some("betaflight"),
)
.with_services(12, 1, 2, &["ados-video".to_string()])
.with_resources(Some(12.5), Some(48.0), Some(31.2), Some(52.0))
.with_payload(Some("ready"), Some("streaming"), Some(2));
let (bytes, trimmed) = s.encode().unwrap();
assert_eq!(trimmed, 0);
assert_eq!(NodeStatus::decode(&bytes).unwrap(), s);
}
#[test]
fn a_realistic_snapshot_is_a_small_fraction_of_the_budget() {
let s = NodeStatus::new("abcdef123456", 7)
.with_agent(Some(3600), Some("0.99.241"))
.with_board(Some("rock-5c-lite"), Some("rk3588s2"), Some(3))
.with_fc(Some(true), Some(true), None, Some("ardupilot"))
.with_services(14, 0, 3, &[])
.with_resources(Some(12.5), Some(48.0), Some(31.2), Some(52.0))
.with_payload(Some("ready"), Some("streaming"), Some(2));
let (bytes, _) = s.encode().unwrap();
assert!(
bytes.len() < AUX_MAX_PAYLOAD / 3,
"realistic snapshot grew to {} bytes",
bytes.len()
);
}
#[test]
fn the_video_leg_count_crosses_the_lane_and_unknown_stays_unknown() {
let two = NodeStatus::new("abcdef123456", 1).with_payload(None, None, Some(2));
let back = NodeStatus::decode(&two.encode().unwrap().0).unwrap();
assert_eq!(back.vn, Some(2));
let unknown = NodeStatus::new("abcdef123456", 1).with_payload(None, None, None);
let back = NodeStatus::decode(&unknown.encode().unwrap().0).unwrap();
assert_eq!(back.vn, None, "unknown must not decode as a count");
}
#[test]
fn absent_fields_cost_nothing_and_stay_absent() {
let bare = NodeStatus::new("abcdef123456", 1);
let (bytes, _) = bare.encode().unwrap();
assert!(bytes.len() < 64, "bare snapshot is {} bytes", bytes.len());
let back = NodeStatus::decode(&bytes).unwrap();
assert_eq!(back.cp, None);
assert_eq!(back.fc, None);
assert_eq!(back.sn, None);
}
#[test]
fn the_trim_ladder_sheds_in_order_and_keeps_the_core() {
let mut s = maximal();
for (i, step) in TRIM_LADDER.iter().enumerate() {
step(&mut s);
assert_eq!(s.v, NODE_STATUS_VERSION, "step {i} dropped the version");
assert!(!s.id.is_empty(), "step {i} dropped the device id");
assert_eq!(s.fc, Some(true), "step {i} dropped fc connected");
assert_eq!(s.fa, Some(true), "step {i} dropped mavlink alive");
}
assert!(s.sn.is_none() && s.cs.is_none() && s.bn.is_none() && s.cp.is_none());
assert_eq!(s.sf, Some(u16::MAX));
}
#[test]
fn a_foreign_or_future_version_is_dropped_not_misread() {
let mut s = NodeStatus::new("abcdef123456", 1);
s.v = NODE_STATUS_VERSION + 1;
let bytes = rmp_serde::to_vec_named(&s).unwrap();
assert!(NodeStatus::decode(&bytes).is_none());
assert!(NodeStatus::decode(b"not msgpack at all").is_none());
}
#[test]
fn an_idless_snapshot_or_identity_is_rejected() {
let s = NodeStatus::new(" ", 1);
let bytes = rmp_serde::to_vec_named(&s).unwrap();
assert!(NodeStatus::decode(&bytes).is_none());
let idy = NodeIdentity::build("", None, None, None, &[]);
let bytes = rmp_serde::to_vec_named(&idy).unwrap();
assert!(NodeIdentity::decode(&bytes).is_none());
}
#[test]
fn identity_round_trips_and_is_bounded() {
let idy = NodeIdentity::build(
&"d".repeat(99),
Some(&"n".repeat(99)),
Some("drone"),
Some("0.99.241"),
&[],
);
assert_eq!(idy.id.chars().count(), MAX_DEVICE_ID);
assert_eq!(idy.nm.as_ref().unwrap().chars().count(), MAX_NAME);
let bytes = idy.encode().expect("encodes");
assert!(bytes.len() <= AUX_MAX_PAYLOAD);
assert_eq!(NodeIdentity::decode(&bytes).unwrap(), idy);
}
#[test]
fn blank_optional_strings_read_as_unknown() {
let s = NodeStatus::new("abc", 1)
.with_board(Some(" "), None, None)
.with_fc(None, None, Some(""), None);
assert_eq!(s.bn, None);
assert_eq!(s.fv, None);
}
#[test]
fn a_multibyte_name_clips_without_breaking_utf8() {
let idy = NodeIdentity::build("abc", Some(&"\u{00e9}".repeat(200)), None, None, &[]);
let name = idy.nm.as_ref().unwrap();
assert_eq!(name.chars().count(), MAX_NAME);
let bytes = idy.encode().unwrap();
assert_eq!(NodeIdentity::decode(&bytes).unwrap().nm, idy.nm);
}
#[test]
fn identity_carries_and_parses_a_plugin_inventory() {
let plugins = vec![
("ados.example.beta".to_string(), 3u16),
("ados.example.alpha".to_string(), 1u16),
];
let idy = NodeIdentity::build(
"abcdef123456",
Some("Drone"),
Some("drone"),
Some("1.0.0"),
&plugins,
);
assert_eq!(
idy.pv,
Some(3),
"pv is the highest contract version present"
);
assert_eq!(
idy.pl.as_deref(),
Some("ados.example.alpha@1,ados.example.beta@3")
);
let bytes = idy.encode().expect("encodes");
let back = NodeIdentity::decode(&bytes).expect("round-trips");
assert_eq!(back, idy);
assert_eq!(
parse_plugin_list(back.pl.as_deref().unwrap()),
vec![
("ados.example.alpha".to_string(), 1),
("ados.example.beta".to_string(), 3)
]
);
}
#[test]
fn an_identity_from_a_future_schema_is_dropped_not_misread() {
let mut raw = NodeIdentity::build("abcdef123456", None, None, None, &[]);
raw.v = NODE_STATUS_VERSION + 1;
let bytes = rmp_serde::to_vec_named(&raw).unwrap();
assert!(NodeIdentity::decode(&bytes).is_none());
}
}