ados_protocol/radio_adapters.rs
1//! Per-adapter WFB radio report: the `adapters[]` + `diversity` blocks of the
2//! `wfb-stats` sidecar (version 2) and the body of `GET /api/v1/radio/adapters`.
3//!
4//! A ground station can run several WFB adapters at once. Every adapter with a
5//! receive role is handed to one `wfb_rx`, which combines their frames before
6//! FEC decode (receive diversity); one adapter carries the transmit lanes. The
7//! receive plane publishes one [`RadioAdapterReport`] per adapter plus a
8//! [`DiversityReport`] comparing the combined loss against the best single
9//! adapter over the same window. The route serves the same objects verbatim.
10//!
11//! Producer: `ados-groundlink` (the receive plane's stats reader). Consumers:
12//! `ados-control` (`/api/v1/radio/adapters`).
13
14use serde::{Deserialize, Serialize};
15
16/// What an adapter is used for.
17///
18/// `rxtx` receives and carries the transmit lanes, `rx` only receives, `tx`
19/// only transmits, and `mesh` is left out of WFB entirely for the batman-adv
20/// mesh carrier.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum AdapterRole {
24 Rxtx,
25 Rx,
26 Tx,
27 Mesh,
28}
29
30impl AdapterRole {
31 /// The wire string (`rxtx`, `rx`, `tx`, `mesh`).
32 pub fn as_str(self) -> &'static str {
33 match self {
34 AdapterRole::Rxtx => "rxtx",
35 AdapterRole::Rx => "rx",
36 AdapterRole::Tx => "tx",
37 AdapterRole::Mesh => "mesh",
38 }
39 }
40
41 /// True for the roles whose interface is handed to `wfb_rx`.
42 pub fn receives(self) -> bool {
43 matches!(self, AdapterRole::Rxtx | AdapterRole::Rx)
44 }
45}
46
47/// One adapter's identity, role and receive counters.
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct RadioAdapterReport {
50 pub iface: String,
51 /// The adapter's MAC address, `None` when it could not be read.
52 pub mac: Option<String>,
53 /// The USB device path (`1-1.2`), `None` when the adapter is not USB-backed.
54 pub usb_path: Option<String>,
55 pub driver: String,
56 pub role: AdapterRole,
57 /// The operator's antenna label from config, `None` when unset.
58 pub antenna: Option<String>,
59 /// True for the adapter carrying the transmit lanes.
60 pub tx_active: bool,
61 /// Average RSSI per receive chain over the last stats interval, in chain
62 /// order. Empty when the adapter received nothing in that interval.
63 pub rssi_dbm: Vec<i32>,
64 /// Frames this adapter received since the receive chain started.
65 pub packets: u64,
66 /// Frames this adapter missed since the receive chain started. An
67 /// estimate with more than one receive adapter: the decoder's frame total
68 /// minus what this adapter's chains counted, where the per-antenna counts
69 /// include FEC parity frames and the decoder's lost count is data-only.
70 /// Exact (the decoder's own count) with a single receive adapter.
71 pub lost: u64,
72 /// FEC recoveries for this adapter: the decoder's count with a single
73 /// receive adapter. The decoder reports one combined count, so this is
74 /// `None` (null) whenever more than one adapter receives.
75 pub fec_rec: Option<u64>,
76 /// Monitor mode was set and read back on this adapter.
77 pub verified: bool,
78}
79
80/// Combined-versus-single receive loss over one window.
81#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
82pub struct DiversityReport {
83 /// Adapters handed to `wfb_rx` (`rx` and `rxtx` roles).
84 pub rx_adapters: u32,
85 /// Loss after combining every receive adapter, in percent. `None` until a
86 /// window carried traffic.
87 pub combined_loss_pct: Option<f64>,
88 /// Loss of the best single receive adapter over the same window, in
89 /// percent. `None` until a window carried traffic.
90 pub best_single_loss_pct: Option<f64>,
91}
92
93/// The `{present, adapters, diversity}` body. Default is the honest empty shape
94/// served when no receive plane is reporting (`present: false`, no adapters,
95/// null loss figures).
96#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
97pub struct RadioAdaptersBody {
98 /// True when a receive plane is reporting a current per-adapter picture.
99 /// The receive plane writes only `adapters` and `diversity` into the
100 /// `wfb-stats` sidecar; the reader sets this.
101 #[serde(default)]
102 pub present: bool,
103 #[serde(default)]
104 pub adapters: Vec<RadioAdapterReport>,
105 #[serde(default)]
106 pub diversity: DiversityReport,
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 #[test]
114 fn the_empty_body_matches_the_absent_shape() {
115 let v = serde_json::to_value(RadioAdaptersBody::default()).unwrap();
116 assert_eq!(
117 v,
118 serde_json::json!({
119 "present": false,
120 "adapters": [],
121 "diversity": {
122 "rx_adapters": 0,
123 "combined_loss_pct": null,
124 "best_single_loss_pct": null,
125 },
126 })
127 );
128 }
129
130 #[test]
131 fn roles_use_the_lowercase_wire_names() {
132 for role in [
133 AdapterRole::Rxtx,
134 AdapterRole::Rx,
135 AdapterRole::Tx,
136 AdapterRole::Mesh,
137 ] {
138 assert_eq!(
139 serde_json::to_value(role).unwrap(),
140 serde_json::json!(role.as_str())
141 );
142 }
143 assert!(AdapterRole::Rxtx.receives());
144 assert!(AdapterRole::Rx.receives());
145 assert!(!AdapterRole::Tx.receives());
146 assert!(!AdapterRole::Mesh.receives());
147 }
148}