Skip to main content

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}