//! 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 { fs::read_to_string(Path::new("/sys/class/net").join(iface).join(name)).ok().map(|s| s.trim().to_string()) } fn wifi_iface() -> Option { 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 { 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 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 { 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, } 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::(&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::>().join(",")) .unwrap_or_default(), signal: g["signal-quality"]["value"].as_str().and_then(|s| s.parse().ok()), } } pub struct Net { wifi: Option, wifi_rate: Rate, ssid: Option<(Instant, Option)>, lte_rate: Rate, tablet_rate: Rate, modem: Arc>>, } 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))); } } }