use std::path::Path;
use serde_json::{json, Map, Value};
mod radio_block;
mod reg_domain;
pub use radio_block::{build_radio_block, radio_absent_block, RSSI_SENTINEL};
#[cfg(test)]
use reg_domain::*;
pub use reg_domain::{refresh_regulatory_domain, regulatory_domain};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WfbChannel {
pub channel_number: i64,
pub frequency_mhz: i64,
pub bandwidth_mhz: i64,
}
pub const STANDARD_CHANNELS: [WfbChannel; 9] = [
WfbChannel {
channel_number: 36,
frequency_mhz: 5180,
bandwidth_mhz: 20,
},
WfbChannel {
channel_number: 40,
frequency_mhz: 5200,
bandwidth_mhz: 20,
},
WfbChannel {
channel_number: 44,
frequency_mhz: 5220,
bandwidth_mhz: 20,
},
WfbChannel {
channel_number: 48,
frequency_mhz: 5240,
bandwidth_mhz: 20,
},
WfbChannel {
channel_number: 149,
frequency_mhz: 5745,
bandwidth_mhz: 20,
},
WfbChannel {
channel_number: 153,
frequency_mhz: 5765,
bandwidth_mhz: 20,
},
WfbChannel {
channel_number: 157,
frequency_mhz: 5785,
bandwidth_mhz: 20,
},
WfbChannel {
channel_number: 161,
frequency_mhz: 5805,
bandwidth_mhz: 20,
},
WfbChannel {
channel_number: 165,
frequency_mhz: 5825,
bandwidth_mhz: 20,
},
];
pub fn get_channel(channel_number: i64) -> Option<WfbChannel> {
STANDARD_CHANNELS
.iter()
.find(|c| c.channel_number == channel_number)
.copied()
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct WfbStatusConfig {
pub channel: i64,
pub tx_power_dbm: Value,
pub tx_power_max_dbm: Value,
pub topology: Value,
pub mcs_index: Value,
}
impl Default for WfbStatusConfig {
fn default() -> Self {
WfbStatusConfig {
channel: 149,
tx_power_dbm: json!(5),
tx_power_max_dbm: json!(15),
topology: json!("host_vbus"),
mcs_index: json!(1),
}
}
}
impl WfbStatusConfig {
pub fn load(config_path: &Path) -> Self {
let text = match std::fs::read_to_string(config_path) {
Ok(t) => t,
Err(_) => return WfbStatusConfig::default(),
};
let root: Value = match serde_norway::from_str(&text) {
Ok(v) => v,
Err(_) => return WfbStatusConfig::default(),
};
let wfb = root
.get("video")
.filter(|v| v.is_object())
.and_then(|v| v.get("wfb"))
.filter(|v| v.is_object());
let Some(wfb) = wfb else {
return WfbStatusConfig::default();
};
let d = WfbStatusConfig::default();
WfbStatusConfig {
channel: wfb
.get("channel")
.and_then(json_to_i64)
.unwrap_or(d.channel),
tx_power_dbm: wfb.get("tx_power_dbm").cloned().unwrap_or(d.tx_power_dbm),
tx_power_max_dbm: wfb
.get("tx_power_max_dbm")
.cloned()
.unwrap_or(d.tx_power_max_dbm),
topology: wfb.get("topology").cloned().unwrap_or(d.topology),
mcs_index: wfb.get("mcs_index").cloned().unwrap_or(d.mcs_index),
}
}
}
pub const WFB_STALE_AGE_US: i64 = 10_000_000;
pub const WFB_STALE_AGE_S: f64 = 10.0;
pub fn wfb_base_block(cfg: &WfbStatusConfig) -> Map<String, Value> {
let mut block = Map::new();
block.insert("state".to_string(), json!("disabled"));
block.insert("interface".to_string(), json!(""));
block.insert("channel".to_string(), json!(cfg.channel));
block.insert("actual_channel".to_string(), Value::Null);
block.insert("frequency_mhz".to_string(), json!(0));
block.insert("bandwidth_mhz".to_string(), json!(0));
block.insert(
"adapter".to_string(),
json!({"driver": "", "chipset": "", "supports_monitor": false}),
);
block.insert("adapter_chipset".to_string(), Value::Null);
block.insert("adapter_injection_ok".to_string(), Value::Null);
block.insert("rssi_dbm".to_string(), Value::Null);
block.insert("noise_dbm".to_string(), Value::Null);
block.insert("snr_db".to_string(), Value::Null);
block.insert("packets_received".to_string(), json!(0));
block.insert("packets_lost".to_string(), json!(0));
block.insert("loss_percent".to_string(), json!(0.0));
block.insert("fec_recovered".to_string(), json!(0));
block.insert("fec_failed".to_string(), json!(0));
block.insert("bitrate_kbps".to_string(), json!(0));
block.insert("rx_silent_seconds".to_string(), Value::Null);
block.insert("restart_count".to_string(), json!(0));
block.insert("samples".to_string(), json!(0));
block.insert("tx_power_dbm".to_string(), cfg.tx_power_dbm.clone());
block.insert("tx_power_max_dbm".to_string(), cfg.tx_power_max_dbm.clone());
block.insert("tx_power_readback_dbm".to_string(), Value::Null);
block.insert("topology".to_string(), cfg.topology.clone());
block.insert("mcs_index".to_string(), cfg.mcs_index.clone());
block.insert("regulatory_domain".to_string(), json!(regulatory_domain()));
block
}
pub fn derive_wfb_status(detail: &Map<String, Value>, ts_us: i64, cfg: &WfbStatusConfig) -> Value {
let mut merged = wfb_base_block(cfg);
for (k, v) in detail {
merged.insert(k.clone(), v.clone());
}
let now_us = now_unix_micros();
if ts_us > 0 && now_us - ts_us > WFB_STALE_AGE_US {
merged.insert("state".to_string(), json!("stale"));
}
finalize_wfb_status(merged)
}
pub fn build_status_from_stats_file_at(cfg: &WfbStatusConfig, path: &Path) -> Value {
let base = wfb_base_block(cfg);
let age_s = match std::fs::metadata(path).and_then(|m| m.modified()) {
Ok(mtime) => mtime.elapsed().map(|d| d.as_secs_f64()).unwrap_or(0.0),
Err(_) => return Value::Object(base),
};
let payload = match std::fs::read_to_string(path) {
Ok(text) => match serde_json::from_str::<Value>(&text) {
Ok(Value::Object(map)) => map,
Ok(_) => return Value::Object(base),
Err(_) => return Value::Object(base),
},
Err(_) => return Value::Object(base),
};
let got = payload.get("version").and_then(Value::as_u64).unwrap_or(0) as u16;
if let Some(ours) = crate::contracts::sidecar_version("wfb-stats") {
crate::sidecar::check_sidecar_version("wfb-stats", got, ours);
}
let mut merged = base;
for (k, v) in payload {
merged.insert(k, v);
}
if age_s > WFB_STALE_AGE_S {
merged.insert("state".to_string(), json!("stale"));
}
merged.insert("regulatory_domain".to_string(), json!(regulatory_domain()));
finalize_wfb_status(merged)
}
pub fn finalize_wfb_status(mut merged: Map<String, Value>) -> Value {
let channel = merged
.get("actual_channel")
.and_then(json_to_i64)
.unwrap_or(0);
if let Some(ch) = get_channel(channel) {
merged.insert("frequency_mhz".to_string(), json!(ch.frequency_mhz));
merged.insert("bandwidth_mhz".to_string(), json!(ch.bandwidth_mhz));
}
let bitrate_mbps = match merged.get("bitrate_kbps").and_then(Value::as_f64) {
Some(bk) if bk > 0.0 => round3(bk / 1000.0),
_ => 0.0,
};
merged.insert("bitrate_mbps".to_string(), json!(bitrate_mbps));
Value::Object(merged)
}
pub fn get_or_null(map: &Map<String, Value>, key: &str) -> Value {
map.get(key).cloned().unwrap_or(Value::Null)
}
pub fn non_empty_string(v: &Value) -> Option<String> {
match v {
Value::String(s) if !s.is_empty() => Some(s.clone()),
_ => None,
}
}
pub fn json_truthy(v: &Value) -> bool {
match v {
Value::Null => false,
Value::Bool(b) => *b,
Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(true),
Value::String(s) => !s.is_empty(),
Value::Array(a) => !a.is_empty(),
Value::Object(o) => !o.is_empty(),
}
}
pub fn is_falsey(v: &Value) -> bool {
!json_truthy(v)
}
pub fn json_to_i64(v: &Value) -> Option<i64> {
match v {
Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
_ => None,
}
}
pub fn round3(v: f64) -> f64 {
(v * 1000.0).round() / 1000.0
}
pub fn now_unix_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_micros() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests;