Skip to main content

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;