Files
svc-core/src/orin-status/net.rs
T
2026-10-13 16:39:01 +09:00

167 lines
6.2 KiB
Rust

//! Network links: WiFi, LTE and the USB link to the tablet. Up/down and rates
//! from sysfs counters; WiFi signal from /proc/net/wireless, its SSID from `iw`;
//! the modem's own view from ModemManager, polled on a thread because `mmcli` can
//! take seconds.
use std::fs;
use std::path::Path;
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use serde_json::{json, Value};
/// Byte counters of one interface, turned into kbit/s between reads.
struct Rate {
prev: Option<(Instant, u64, u64)>,
}
impl Rate {
fn read(&mut self, iface: &str) -> Option<(f64, f64)> {
let base = Path::new("/sys/class/net").join(iface).join("statistics");
let rx: u64 = fs::read_to_string(base.join("rx_bytes")).ok()?.trim().parse().ok()?;
let tx: u64 = fs::read_to_string(base.join("tx_bytes")).ok()?.trim().parse().ok()?;
let now = Instant::now();
let prev = self.prev.replace((now, rx, tx))?;
let dt = now.duration_since(prev.0).as_secs_f64();
// A counter that went backwards was reset (interface recreated): skip one interval.
if dt <= 0.0 || rx < prev.1 || tx < prev.2 {
return None;
}
let kbps = |d: u64| (d as f64 * 8.0 / 1000.0 / dt).round();
Some((kbps(rx - prev.1), kbps(tx - prev.2)))
}
}
fn attr(iface: &str, name: &str) -> Option<String> {
fs::read_to_string(Path::new("/sys/class/net").join(iface).join(name)).ok().map(|s| s.trim().to_string())
}
fn wifi_iface() -> Option<String> {
fs::read_dir("/sys/class/net")
.ok()?
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.find(|n| Path::new("/sys/class/net").join(n).join("wireless").exists())
}
/// Signal level in dBm from /proc/net/wireless ("wlP1p1s0: 0000 70. -34. -256 ...").
fn wifi_dbm(iface: &str) -> Option<f64> {
let w = fs::read_to_string("/proc/net/wireless").ok()?;
let line = w.lines().find(|l| l.trim_start().starts_with(&format!("{iface}:")))?;
line.split_whitespace().nth(3)?.trim_end_matches('.').parse().ok()
}
/// SSID of the associated network, from `iw dev <iface> link` ("\tSSID: name"); None when
/// not associated. iw prints non-printable bytes as \xNN escapes, which are kept as they are.
fn wifi_ssid(iface: &str) -> Option<String> {
let out = Command::new("iw").args(["dev", iface, "link"]).output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let ssid = text.lines().find_map(|l| l.trim_start().strip_prefix("SSID: "))?;
Some(ssid.to_string())
}
/// How often the SSID is re-read while WiFi stays up; a change of network without a drop is rare.
const SSID_EVERY: Duration = Duration::from_secs(10);
#[derive(Default, Clone)]
struct Modem {
state: String,
tech: String,
signal: Option<f64>,
}
fn poll_modem() -> Modem {
let absent = || Modem { state: "absent".into(), ..Default::default() };
let out = match Command::new("mmcli").args(["-m", "any", "-J"]).output() {
Ok(o) if o.status.success() => o.stdout,
_ => return absent(),
};
let Ok(v) = serde_json::from_slice::<Value>(&out) else { return absent() };
let g = &v["modem"]["generic"];
Modem {
state: g["state"].as_str().unwrap_or("unknown").to_string(),
tech: g["access-technologies"]
.as_array()
.map(|a| a.iter().filter_map(|t| t.as_str()).collect::<Vec<_>>().join(","))
.unwrap_or_default(),
signal: g["signal-quality"]["value"].as_str().and_then(|s| s.parse().ok()),
}
}
pub struct Net {
wifi: Option<String>,
wifi_rate: Rate,
ssid: Option<(Instant, Option<String>)>,
lte_rate: Rate,
tablet_rate: Rate,
modem: Arc<Mutex<Option<Modem>>>,
}
impl Net {
pub fn start() -> Self {
let modem = Arc::new(Mutex::new(None));
let m = modem.clone();
thread::spawn(move || loop {
let now = poll_modem();
*m.lock().unwrap() = Some(now);
thread::sleep(Duration::from_secs(5));
});
Net {
wifi: wifi_iface(),
wifi_rate: Rate { prev: None },
ssid: None,
lte_rate: Rate { prev: None },
tablet_rate: Rate { prev: None },
modem,
}
}
pub fn read(&mut self, out: &mut Vec<(&'static str, Value)>) {
if self.wifi.is_none() {
self.wifi = wifi_iface();
}
if let Some(w) = self.wifi.clone() {
let up = attr(&w, "operstate").as_deref() == Some("up");
out.push(("net_wifi_up", json!(up)));
if !up {
self.ssid = None;
} else if self.ssid.as_ref().is_none_or(|(at, _)| at.elapsed() >= SSID_EVERY) {
self.ssid = Some((Instant::now(), wifi_ssid(&w)));
}
if let Some((_, Some(ssid))) = &self.ssid {
out.push(("net_wifi_ssid", json!(ssid)));
}
if let Some(dbm) = wifi_dbm(&w).filter(|_| up) {
out.push(("net_wifi_signal_dbm", json!(dbm)));
}
if let Some((rx, tx)) = self.wifi_rate.read(&w) {
out.push(("net_wifi_rx_kbps", json!(rx)));
out.push(("net_wifi_tx_kbps", json!(tx)));
}
}
if let Some(m) = self.modem.lock().unwrap().clone() {
out.push(("net_lte_state", json!(m.state)));
out.push(("net_lte_tech", json!(m.tech)));
if let Some(s) = m.signal {
out.push(("net_lte_signal_pct", json!(s)));
}
}
if let Some((rx, tx)) = self.lte_rate.read("wwan0") {
out.push(("net_lte_rx_kbps", json!(rx)));
out.push(("net_lte_tx_kbps", json!(tx)));
}
// The gadget's NCM (usb1) and RNDIS (usb0) functions are bridged on l4tbr0; carrier on
// either means a host has configured the link. The bridge counts the traffic of both.
let up = ["usb0", "usb1"].iter().any(|i| attr(i, "carrier").as_deref() == Some("1"));
out.push(("net_tablet_up", json!(up)));
if let Some((rx, tx)) = self.tablet_rate.read("l4tbr0") {
out.push(("net_tablet_rx_kbps", json!(rx)));
out.push(("net_tablet_tx_kbps", json!(tx)));
}
}
}