ados_protocol/wfb_status/mod.rs
1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 Altnautica — ADOS Drone Agent
3//! The one WFB link-status derivation, and the radio block built from it.
4//!
5//! ## Why this lives here
6//!
7//! Three copies of this derivation existed. `ados-control`'s `/api/wfb` route and
8//! its `/api/status/full` route each carried a private `derive_wfb_status` +
9//! `build_status_from_stats_file` + base block + finalize legs, near-identical and
10//! free to drift; the cloud heartbeat carried no derivation at all and shipped an
11//! `absent` radio block forever, because writing a fourth was the only way to get
12//! one. A same-name fork is the acute form of the reader/producer defect, and it
13//! has already cost this codebase a shipped regression, so the derivation
14//! is implemented once, here, and consumed by every transport.
15//!
16//! `ados-protocol` is the right home: it already owns the frozen wire seams both
17//! `ados-control` and `ados-cloud` depend on, and neither can depend on the other.
18//!
19//! ## Shape
20//!
21//! - [`WfbStatusConfig`] is the `video.wfb` slice the base block seeds from.
22//! - [`wfb_base_block`] is the config-seeded zero default both read paths merge
23//! the producer's payload over.
24//! - [`derive_wfb_status`] maps a stored `link.wfb_status` event body onto it;
25//! [`build_status_from_stats_file_at`] does the same from the `wfb-stats.json`
26//! sidecar. Both end in [`finalize_wfb_status`].
27//! - [`build_radio_block`] projects that status onto the 40-field heartbeat radio
28//! block, and [`radio_absent_block`] is its honest no-radio skeleton.
29//!
30//! Every file read is path-injectable so a caller's test drives a tempdir without
31//! touching the process-global run dir. The one impure edge that is not injected
32//! is [`regulatory_domain`], and it is read by a background task rather than on
33//! the caller's path: every caller here is on the tokio reactor (an axum route
34//! handler, the cloud heartbeat tick), and `iw reg get` on a wedged RTL driver
35//! does not return, so forking it inline parked a reactor worker indefinitely.
36
37use std::path::Path;
38
39use serde_json::{json, Map, Value};
40
41mod radio_block;
42mod reg_domain;
43
44pub use radio_block::{build_radio_block, radio_absent_block, RSSI_SENTINEL};
45#[cfg(test)]
46use reg_domain::*;
47pub use reg_domain::{refresh_regulatory_domain, regulatory_domain};
48
49// ---------------------------------------------------------------------------
50// Channels.
51// ---------------------------------------------------------------------------
52
53/// One standard WFB-ng channel: the number, its centre frequency, and its width.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct WfbChannel {
56 pub channel_number: i64,
57 pub frequency_mhz: i64,
58 pub bandwidth_mhz: i64,
59}
60
61/// The standard 5 GHz channels usable with WFB-ng on the RTL8812 family: the
62/// U-NII-1 sub-band (36/40/44/48) and the U-NII-3 sub-band (149/153/157/161/165),
63/// each 20 MHz wide.
64///
65/// This is the ONE table. The radio block used to carry a second, narrower copy
66/// that omitted 40 and 44 — so a link on either channel rendered a null
67/// `freq_mhz` in the GCS while the status body beside it reported the correct
68/// `frequency_mhz`. The narrower copy was justified in a comment as matching a
69/// Python map that no longer exists.
70pub const STANDARD_CHANNELS: [WfbChannel; 9] = [
71 WfbChannel {
72 channel_number: 36,
73 frequency_mhz: 5180,
74 bandwidth_mhz: 20,
75 },
76 WfbChannel {
77 channel_number: 40,
78 frequency_mhz: 5200,
79 bandwidth_mhz: 20,
80 },
81 WfbChannel {
82 channel_number: 44,
83 frequency_mhz: 5220,
84 bandwidth_mhz: 20,
85 },
86 WfbChannel {
87 channel_number: 48,
88 frequency_mhz: 5240,
89 bandwidth_mhz: 20,
90 },
91 WfbChannel {
92 channel_number: 149,
93 frequency_mhz: 5745,
94 bandwidth_mhz: 20,
95 },
96 WfbChannel {
97 channel_number: 153,
98 frequency_mhz: 5765,
99 bandwidth_mhz: 20,
100 },
101 WfbChannel {
102 channel_number: 157,
103 frequency_mhz: 5785,
104 bandwidth_mhz: 20,
105 },
106 WfbChannel {
107 channel_number: 161,
108 frequency_mhz: 5805,
109 bandwidth_mhz: 20,
110 },
111 WfbChannel {
112 channel_number: 165,
113 frequency_mhz: 5825,
114 bandwidth_mhz: 20,
115 },
116];
117
118/// Look up a channel by number, or `None` for an unknown number.
119pub fn get_channel(channel_number: i64) -> Option<WfbChannel> {
120 STANDARD_CHANNELS
121 .iter()
122 .find(|c| c.channel_number == channel_number)
123 .copied()
124}
125
126// ---------------------------------------------------------------------------
127// Config seam.
128// ---------------------------------------------------------------------------
129
130/// The `video.wfb` config slice the WFB status base block seeds from. These are
131/// configured values, not measurements: an absent file, section or key reads
132/// the schema default the radio itself runs at (channel 149, 5/15 dBm,
133/// `host_vbus`, MCS 1), so the base block never reports a channel or power the
134/// radio is not configured for.
135#[derive(Debug, Clone, serde::Serialize)]
136pub struct WfbStatusConfig {
137 pub channel: i64,
138 pub tx_power_dbm: Value,
139 pub tx_power_max_dbm: Value,
140 pub topology: Value,
141 pub mcs_index: Value,
142}
143
144impl Default for WfbStatusConfig {
145 fn default() -> Self {
146 WfbStatusConfig {
147 channel: 149,
148 tx_power_dbm: json!(5),
149 tx_power_max_dbm: json!(15),
150 topology: json!("host_vbus"),
151 mcs_index: json!(1),
152 }
153 }
154}
155
156impl WfbStatusConfig {
157 /// Load the `video.wfb` slice from a config path, defaulting every field when
158 /// the file / section / key is absent or unparseable. Takes the path rather
159 /// than resolving it, because the two callers resolve it differently (the
160 /// front from its pairing paths, the cloud relay from `ADOS_CONFIG`).
161 pub fn load(config_path: &Path) -> Self {
162 let text = match std::fs::read_to_string(config_path) {
163 Ok(t) => t,
164 Err(_) => return WfbStatusConfig::default(),
165 };
166 let root: Value = match serde_norway::from_str(&text) {
167 Ok(v) => v,
168 Err(_) => return WfbStatusConfig::default(),
169 };
170 let wfb = root
171 .get("video")
172 .filter(|v| v.is_object())
173 .and_then(|v| v.get("wfb"))
174 .filter(|v| v.is_object());
175 let Some(wfb) = wfb else {
176 return WfbStatusConfig::default();
177 };
178 let d = WfbStatusConfig::default();
179 WfbStatusConfig {
180 channel: wfb
181 .get("channel")
182 .and_then(json_to_i64)
183 .unwrap_or(d.channel),
184 tx_power_dbm: wfb.get("tx_power_dbm").cloned().unwrap_or(d.tx_power_dbm),
185 tx_power_max_dbm: wfb
186 .get("tx_power_max_dbm")
187 .cloned()
188 .unwrap_or(d.tx_power_max_dbm),
189 topology: wfb.get("topology").cloned().unwrap_or(d.topology),
190 mcs_index: wfb.get("mcs_index").cloned().unwrap_or(d.mcs_index),
191 }
192 }
193}
194
195// ---------------------------------------------------------------------------
196// The status derivation.
197// ---------------------------------------------------------------------------
198
199/// Beyond this age (microseconds) a stored status event is treated as stale,
200/// mirroring the sidecar path's `mtime > 10 s` flip.
201pub const WFB_STALE_AGE_US: i64 = 10_000_000;
202
203/// The staleness ceiling in seconds, the same number [`WFB_STALE_AGE_US`] carries.
204/// A snapshot older than this renders a dead link as if it were live, so every
205/// transport gates on it.
206pub const WFB_STALE_AGE_S: f64 = 10.0;
207
208/// The config-seeded zero-default WFB status block both read paths merge over.
209/// `regulatory_domain` is the live (TTL-cached) `iw reg get` value.
210pub fn wfb_base_block(cfg: &WfbStatusConfig) -> Map<String, Value> {
211 let mut block = Map::new();
212 block.insert("state".to_string(), json!("disabled"));
213 block.insert("interface".to_string(), json!(""));
214 // `channel` is the configured home channel; `actual_channel` is the live
215 // interface channel, null until a producer reports one.
216 block.insert("channel".to_string(), json!(cfg.channel));
217 block.insert("actual_channel".to_string(), Value::Null);
218 block.insert("frequency_mhz".to_string(), json!(0));
219 block.insert("bandwidth_mhz".to_string(), json!(0));
220 block.insert(
221 "adapter".to_string(),
222 json!({"driver": "", "chipset": "", "supports_monitor": false}),
223 );
224 block.insert("adapter_chipset".to_string(), Value::Null);
225 // Null, not false: this base is what `build_radio_block` receives when no
226 // sidecar / stored status exists — i.e. no adapter scan ever produced a
227 // verdict. A false here would be a real boolean `bool_or_null` forwards
228 // verbatim, resurrecting the fabricated measured no-injection claim the radio
229 // block's null contract exists to prevent. A real sidecar always carries the
230 // key, so a genuine verdict overwrites this on merge.
231 block.insert("adapter_injection_ok".to_string(), Value::Null);
232 // Null, not a plausible reading. This base is what a node with NO radio
233 // serves: no adapter, no producer, nothing ever sampled. A `-100.0` RSSI
234 // beside a `-95.0` noise floor and a `0.0` SNR is a complete, credible weak
235 // link, and it reaches the GCS signal meter, the durable link series and the
236 // operator as a measurement. The sentinel-to-null fold in
237 // `build_radio_block` only covered `rssi_dbm`, so the noise floor and SNR
238 // rode the heartbeat verbatim.
239 block.insert("rssi_dbm".to_string(), Value::Null);
240 block.insert("noise_dbm".to_string(), Value::Null);
241 block.insert("snr_db".to_string(), Value::Null);
242 block.insert("packets_received".to_string(), json!(0));
243 block.insert("packets_lost".to_string(), json!(0));
244 block.insert("loss_percent".to_string(), json!(0.0));
245 block.insert("fec_recovered".to_string(), json!(0));
246 block.insert("fec_failed".to_string(), json!(0));
247 block.insert("bitrate_kbps".to_string(), json!(0));
248 block.insert("rx_silent_seconds".to_string(), Value::Null);
249 block.insert("restart_count".to_string(), json!(0));
250 block.insert("samples".to_string(), json!(0));
251 block.insert("tx_power_dbm".to_string(), cfg.tx_power_dbm.clone());
252 block.insert("tx_power_max_dbm".to_string(), cfg.tx_power_max_dbm.clone());
253 block.insert("tx_power_readback_dbm".to_string(), Value::Null);
254 block.insert("topology".to_string(), cfg.topology.clone());
255 block.insert("mcs_index".to_string(), cfg.mcs_index.clone());
256 block.insert("regulatory_domain".to_string(), json!(regulatory_domain()));
257 block
258}
259
260/// Map a stored `link.wfb_status` event body onto the status shape: the
261/// config-seeded base, the body merged over it, an event-age staleness flip, then
262/// the shared finalize legs.
263///
264/// The base `regulatory_domain` (the live reading) stays put: the stored body
265/// carries `reg_domain`, a different key, so the merge never overwrites it.
266pub fn derive_wfb_status(detail: &Map<String, Value>, ts_us: i64, cfg: &WfbStatusConfig) -> Value {
267 let mut merged = wfb_base_block(cfg);
268 for (k, v) in detail {
269 merged.insert(k.clone(), v.clone());
270 }
271 let now_us = now_unix_micros();
272 if ts_us > 0 && now_us - ts_us > WFB_STALE_AGE_US {
273 merged.insert("state".to_string(), json!("stale"));
274 }
275 finalize_wfb_status(merged)
276}
277
278/// Compose a status body from a `wfb-stats.json` sidecar at `path`: merge the file
279/// payload over the config-seeded base, flip `state` to `"stale"` when the file is
280/// older than [`WFB_STALE_AGE_S`], re-assert the live regulatory domain, and
281/// finalize.
282///
283/// An absent / unreadable / unparseable / non-object body degrades to the bare
284/// base block (no finalize legs) — the shape a caller with no producer must see.
285pub fn build_status_from_stats_file_at(cfg: &WfbStatusConfig, path: &Path) -> Value {
286 let base = wfb_base_block(cfg);
287
288 // The mtime drives the staleness flip; compute the file age in seconds.
289 let age_s = match std::fs::metadata(path).and_then(|m| m.modified()) {
290 Ok(mtime) => mtime.elapsed().map(|d| d.as_secs_f64()).unwrap_or(0.0),
291 Err(_) => return Value::Object(base),
292 };
293
294 let payload = match std::fs::read_to_string(path) {
295 Ok(text) => match serde_json::from_str::<Value>(&text) {
296 Ok(Value::Object(map)) => map,
297 Ok(_) => return Value::Object(base),
298 Err(_) => return Value::Object(base),
299 },
300 Err(_) => return Value::Object(base),
301 };
302
303 // Best-effort schema-drift signal (never reject): warn when the sidecar was
304 // written by an agent with a different schema version, then read anyway. The
305 // writer const lives in the radio crate, so compare against the registry.
306 let got = payload.get("version").and_then(Value::as_u64).unwrap_or(0) as u16;
307 if let Some(ours) = crate::contracts::sidecar_version("wfb-stats") {
308 crate::sidecar::check_sidecar_version("wfb-stats", got, ours);
309 }
310
311 let mut merged = base;
312 for (k, v) in payload {
313 merged.insert(k, v);
314 }
315 if age_s > WFB_STALE_AGE_S {
316 merged.insert("state".to_string(), json!("stale"));
317 }
318 // Re-assert the live regulatory domain over the payload (the file's body must
319 // not overwrite it).
320 merged.insert("regulatory_domain".to_string(), json!(regulatory_domain()));
321 finalize_wfb_status(merged)
322}
323
324/// Re-derive `frequency_mhz` / `bandwidth_mhz` from the live channel and add the
325/// `bitrate_mbps` shim, on top of a base+payload merge.
326pub fn finalize_wfb_status(mut merged: Map<String, Value>) -> Value {
327 let channel = merged
328 .get("actual_channel")
329 .and_then(json_to_i64)
330 .unwrap_or(0);
331 if let Some(ch) = get_channel(channel) {
332 merged.insert("frequency_mhz".to_string(), json!(ch.frequency_mhz));
333 merged.insert("bandwidth_mhz".to_string(), json!(ch.bandwidth_mhz));
334 }
335 let bitrate_mbps = match merged.get("bitrate_kbps").and_then(Value::as_f64) {
336 Some(bk) if bk > 0.0 => round3(bk / 1000.0),
337 _ => 0.0,
338 };
339 merged.insert("bitrate_mbps".to_string(), json!(bitrate_mbps));
340 Value::Object(merged)
341}
342
343// ---------------------------------------------------------------------------
344// JSON helpers shared by the derivation and its callers.
345// ---------------------------------------------------------------------------
346
347/// Look up `key` and return its value, or JSON `null` when absent.
348pub fn get_or_null(map: &Map<String, Value>, key: &str) -> Value {
349 map.get(key).cloned().unwrap_or(Value::Null)
350}
351
352/// A non-empty owned string for a JSON string value, or `None` for a non-string /
353/// empty string.
354pub fn non_empty_string(v: &Value) -> Option<String> {
355 match v {
356 Value::String(s) if !s.is_empty() => Some(s.clone()),
357 _ => None,
358 }
359}
360
361/// Python `bool(x)` over a JSON value: `null`/`false`/`0`/`0.0`/`""`/`[]`/`{}` are
362/// falsey, everything else truthy.
363pub fn json_truthy(v: &Value) -> bool {
364 match v {
365 Value::Null => false,
366 Value::Bool(b) => *b,
367 Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(true),
368 Value::String(s) => !s.is_empty(),
369 Value::Array(a) => !a.is_empty(),
370 Value::Object(o) => !o.is_empty(),
371 }
372}
373
374/// The `x or None` falsiness used for `channel` / `bitrate_kbps` (a `0` / `0.0` /
375/// `null` reads as no value → `null`).
376pub fn is_falsey(v: &Value) -> bool {
377 !json_truthy(v)
378}
379
380/// Coerce a JSON number to `i64`, accepting an integer or a float. `None` for a
381/// non-number.
382pub fn json_to_i64(v: &Value) -> Option<i64> {
383 match v {
384 Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
385 _ => None,
386 }
387}
388
389/// Round to three decimal places.
390pub fn round3(v: f64) -> f64 {
391 (v * 1000.0).round() / 1000.0
392}
393
394/// The current wall-clock time in microseconds since the Unix epoch.
395pub fn now_unix_micros() -> i64 {
396 use std::time::{SystemTime, UNIX_EPOCH};
397 SystemTime::now()
398 .duration_since(UNIX_EPOCH)
399 .map(|d| d.as_micros() as i64)
400 .unwrap_or(0)
401}
402
403#[cfg(test)]
404mod tests;