orin-status: the Orin's health on the bus

Temperatures, CPU/GPU load, memory, module power, fan, WiFi/LTE/tablet links
and the UPS battery (INA219, read-only) as envelopes on yukart/host/<signal>,
once a second. Built natively on the development Orin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
This commit is contained in:
2026-10-12 16:33:31 +09:00
parent 1f678fe87f
commit 0c45e68045
8 changed files with 854 additions and 1 deletions
Generated
+212
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cc"
version = "1.6.0"
@@ -36,6 +42,54 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flume"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
dependencies = [
"futures-core",
"futures-sink",
"spin",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-sink",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -69,12 +123,44 @@ version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "mio"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.34"
@@ -105,6 +191,29 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rumqttc"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0feff8d882bff0b2fddaf99355a10336d43dd3ed44204f85ece28cf9626ab519"
dependencies = [
"bytes",
"fixedbitset",
"flume",
"futures-util",
"log",
"thiserror",
"tokio",
"tokio-stream",
"tokio-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.229"
@@ -173,11 +282,37 @@ dependencies = [
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
dependencies = [
"lock_api",
]
[[package]]
name = "svc-core"
version = "0.1.0"
dependencies = [
"libc",
"rumqttc",
"serde_json",
"signal-hook",
"zstd",
@@ -194,12 +329,89 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-stream"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"libc",
"pin-project-lite",
"tokio",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
+6 -1
View File
@@ -2,15 +2,20 @@
name = "svc-core"
version = "0.1.0"
edition = "2021"
description = "Acquisition and processing cluster: recorder now; can-bridge, obd-poller, gnss, decoder later"
description = "Acquisition and processing cluster: recorder and orin-status now; can-bridge, obd-poller, gnss, decoder later"
publish = false
[[bin]]
name = "recorder"
path = "src/recorder/main.rs"
[[bin]]
name = "orin-status"
path = "src/orin-status/main.rs"
[dependencies]
libc = "0.2"
rumqttc = { version = "0.25", default-features = false }
serde_json = "1"
signal-hook = "0.3"
zstd = "0.13"
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=Yukart orin-status: the Orin's own health on the bus
Documentation=gitea:yukart/svc-core
# Tier 2. Ordered after the broker and nothing else; it reconnects by itself if the broker restarts.
After=mosquitto.service
StartLimitIntervalSec=0
[Service]
User=mikkeli
# The UPS INA219 is on /dev/i2c-7, group i2c.
SupplementaryGroups=i2c
ExecStart=/usr/local/bin/yukart-orin-status
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Build orin-status natively on the development Orin, check it against the car's
# glibc, and install it on the car Orin as the yukart-orin-status service.
#
# usage: scripts/deploy-orin-status.sh [host] (default: mikkeli-orin-nano)
# env: BUILD_HOST (default: mikkeli-orin-nano-2)
#
# Builds what is pushed, not the working tree: the build host pulls master of
# svc-core and contracts under ~/dev/yukart/. Native on purpose: orin-status runs
# mmcli, and std::process::Command cross-compiled against the workstation's newer
# glibc links GLIBC_2.39 symbols the car cannot load.
set -euo pipefail
HOST="${1:-mikkeli-orin-nano}"
BUILD_HOST="${BUILD_HOST:-mikkeli-orin-nano-2}"
cd "$(dirname "$0")/.."
BIN="$(mktemp -d)/orin-status"
ssh "$BUILD_HOST" 'set -e
mkdir -p ~/dev/yukart && cd ~/dev/yukart
for r in svc-core contracts; do
[ -d $r ] || git clone -q http://192.168.2.199:3005/yukart/$r.git
git -C $r pull -q --ff-only
done
cd svc-core && ~/.cargo/bin/cargo build --release --bin orin-status 2>&1 | tail -1
git log --oneline -1'
scp -q "$BUILD_HOST:dev/yukart/svc-core/target/release/orin-status" "$BIN"
scripts/check-glibc.sh "$BIN"
# Binary lives under ~/infra per the host repo's guidelines; /usr/local/bin gets a symlink.
ssh "$HOST" 'mkdir -p ~/infra/svc-core'
scp -q "$BIN" "$HOST:infra/svc-core/orin-status.new"
scp -q deploy/yukart-orin-status.service "$HOST:infra/svc-core/yukart-orin-status.service"
ssh "$HOST" 'set -e
mv ~/infra/svc-core/orin-status.new ~/infra/svc-core/orin-status
sudo ln -sfn "$HOME/infra/svc-core/orin-status" /usr/local/bin/yukart-orin-status
sudo install -m 0644 ~/infra/svc-core/yukart-orin-status.service /etc/systemd/system/yukart-orin-status.service
sudo systemctl daemon-reload
sudo systemctl enable yukart-orin-status.service >/dev/null
sudo systemctl restart yukart-orin-status.service
sleep 1
systemctl --no-pager --lines=5 status yukart-orin-status.service'
+122
View File
@@ -0,0 +1,122 @@
//! The Orin's own health, from sysfs and procfs: temperatures, CPU and GPU load,
//! the shared memory pool, module input power and the fan.
//!
//! Every reading is optional. A sensor that is absent or busy (the `cv*` thermal
//! zones return EAGAIN while the vision accelerators are powered off) yields
//! nothing, and the signal goes unpublished rather than reported as zero.
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::{json, Value};
pub struct Host {
zones: Vec<(String, PathBuf)>, // (thermal zone type, temp file)
gpu_load: Option<PathBuf>,
power: Option<(PathBuf, PathBuf)>, // VDD_IN (millivolts, milliamps)
fan: Option<PathBuf>,
cpu_prev: Option<(u64, u64)>, // (busy, total) jiffies
}
fn read_num(p: &Path) -> Option<f64> {
fs::read_to_string(p).ok()?.trim().parse().ok()
}
fn round(v: f64, places: i32) -> f64 {
let m = 10f64.powi(places);
(v * m).round() / m
}
fn hwmon(name: &str) -> Option<PathBuf> {
fs::read_dir("/sys/class/hwmon").ok()?.flatten().map(|e| e.path()).find(|p| {
fs::read_to_string(p.join("name")).map(|n| n.trim() == name).unwrap_or(false)
})
}
impl Host {
pub fn discover() -> Self {
let mut zones = Vec::new();
if let Ok(rd) = fs::read_dir("/sys/class/thermal") {
for e in rd.flatten() {
let p = e.path();
if let Ok(t) = fs::read_to_string(p.join("type")) {
zones.push((t.trim().to_string(), p.join("temp")));
}
}
}
let gpu_load = fs::read_dir("/sys/devices/platform/bus@0")
.ok()
.and_then(|rd| rd.flatten().map(|e| e.path().join("load")).find(|p| p.to_string_lossy().contains(".gpu/")));
let power = hwmon("ina3221").and_then(|h| {
(1..=8).find_map(|i| {
let label = fs::read_to_string(h.join(format!("in{i}_label"))).ok()?;
(label.trim() == "VDD_IN").then(|| (h.join(format!("in{i}_input")), h.join(format!("curr{i}_input"))))
})
});
let fan = hwmon("pwm_tach").map(|h| h.join("rpm")).filter(|p| p.exists());
Host { zones, gpu_load, power, fan, cpu_prev: None }
}
/// Hottest zone whose type starts with `prefix`, in °C.
fn temp(&self, prefix: &str) -> Option<f64> {
self.zones
.iter()
.filter(|(t, _)| t.starts_with(prefix))
.filter_map(|(_, p)| read_num(p))
.map(|m| m / 1000.0)
.reduce(f64::max)
}
fn cpu_pct(&mut self) -> Option<f64> {
let stat = fs::read_to_string("/proc/stat").ok()?;
let f: Vec<u64> = stat.lines().next()?.split_whitespace().skip(1).filter_map(|x| x.parse().ok()).collect();
if f.len() < 5 {
return None;
}
let total: u64 = f.iter().take(8).sum();
let idle = f[3] + f[4]; // idle + iowait
let now = (total - idle, total);
let prev = self.cpu_prev.replace(now)?;
let dt = now.1.saturating_sub(prev.1);
(dt > 0).then(|| 100.0 * now.0.saturating_sub(prev.0) as f64 / dt as f64)
}
pub fn read(&mut self, out: &mut Vec<(&'static str, Value)>) {
for (name, prefix) in [
("orin_cpu_temp_c", "cpu"),
("orin_gpu_temp_c", "gpu"),
("orin_soc_temp_c", "soc"),
("orin_tj_temp_c", "tj"),
] {
if let Some(t) = self.temp(prefix) {
out.push((name, json!(round(t, 1))));
}
}
if let Some(c) = self.cpu_pct() {
out.push(("orin_cpu_pct", json!(round(c, 1))));
}
if let Some(g) = self.gpu_load.as_deref().and_then(read_num) {
out.push(("orin_gpu_pct", json!(round(g / 10.0, 1)))); // tenths of a percent
}
if let Ok(mem) = fs::read_to_string("/proc/meminfo") {
let kb = |key: &str| -> Option<f64> {
mem.lines().find(|l| l.starts_with(key))?.split_whitespace().nth(1)?.parse().ok()
};
if let (Some(total), Some(avail)) = (kb("MemTotal:"), kb("MemAvailable:")) {
out.push(("orin_ram_used_mb", json!(((total - avail) / 1024.0).round())));
out.push(("orin_ram_used_pct", json!(round(100.0 * (total - avail) / total, 1))));
}
if let (Some(total), Some(free)) = (kb("SwapTotal:"), kb("SwapFree:")) {
out.push(("orin_swap_used_mb", json!(((total - free) / 1024.0).round())));
}
}
if let Some((mv, ma)) = &self.power {
if let (Some(mv), Some(ma)) = (read_num(mv), read_num(ma)) {
out.push(("orin_power_w", json!(round(mv * ma / 1e6, 2))));
}
}
if let Some(rpm) = self.fan.as_deref().and_then(read_num) {
out.push(("orin_fan_rpm", json!(rpm.round())));
}
}
}
+184
View File
@@ -0,0 +1,184 @@
//! orin-status — the Orin's own health on the bus, once a second.
//!
//! Temperatures, CPU and GPU load, the shared memory pool, module power, the fan,
//! network links (WiFi, LTE, the USB link to the tablet) and the UPS battery, each
//! as an envelope on `yukart/host/<signal>`, retained. Names and units come from
//! contracts' `signals.json` (source `host`).
//!
//! A reading that fails is not published: its retained value ages and svc-gw shows
//! it stale, which is the honest picture. The service's own state goes on
//! `yukart/system/orin-status`, with a last will for when it vanishes.
mod host;
mod net;
mod ups;
use std::process::ExitCode;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use rumqttc::{Client, Event, LastWill, MqttOptions, Packet, QoS};
use serde_json::{json, Value};
const USAGE: &str = "\
usage: orin-status [options]
--broker HOST:PORT MQTT broker (127.0.0.1:1883)
--i2c DEV I²C bus of the UPS INA219 (/dev/i2c-7)
--ups-addr ADDR INA219 address, hex (41)
--once print one reading as JSON and exit (no broker)";
const REGISTRY: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../contracts/schema/signals.json"));
const SYSTEM_TOPIC: &str = "yukart/system/orin-status";
fn now_us() -> i64 {
let d = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
d.as_micros() as i64
}
fn mono_ns() -> i64 {
let mut ts: libc::timespec = unsafe { std::mem::zeroed() };
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
ts.tv_sec as i64 * 1_000_000_000 + ts.tv_nsec as i64
}
/// Units by signal name, from the registry: never authored here (contracts guideline 1).
fn units() -> std::collections::HashMap<String, String> {
let v: Value = serde_json::from_str(REGISTRY).expect("contracts signals.json is valid JSON");
v["signals"]
.as_object()
.expect("signals.json has a signals object")
.iter()
.map(|(k, s)| (k.clone(), s["unit"].as_str().unwrap_or("").to_string()))
.collect()
}
struct Sources {
host: host::Host,
net: net::Net,
ups: ups::Ups,
}
impl Sources {
/// One reading of everything, and the names of sources that failed.
fn read(&mut self) -> (Vec<(&'static str, Value)>, Vec<&'static str>) {
let mut out = Vec::new();
let mut failed = Vec::new();
self.host.read(&mut out);
self.net.read(&mut out);
if !self.ups.read(&mut out) {
failed.push("ups");
}
(out, failed)
}
}
fn main() -> ExitCode {
let mut broker = "127.0.0.1:1883".to_string();
let mut i2c = "/dev/i2c-7".to_string();
let mut addr: u16 = 0x41;
let mut once = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--once" => once = true,
"--broker" => broker = args.next().unwrap_or_default(),
"--i2c" => i2c = args.next().unwrap_or_default(),
"--ups-addr" => match args.next().and_then(|v| u16::from_str_radix(v.trim_start_matches("0x"), 16).ok()) {
Some(v) => addr = v,
None => {
eprintln!("orin-status: --ups-addr needs a hex address\n{USAGE}");
return ExitCode::from(2);
}
},
_ => {
eprintln!("orin-status: unknown option {a}\n{USAGE}");
return ExitCode::from(2);
}
}
}
let units = units();
let mut src = Sources { host: host::Host::discover(), net: net::Net::start(), ups: ups::Ups::new(&i2c, addr) };
if once {
src.read(); // rates and CPU load need two readings
thread::sleep(Duration::from_secs(1));
let (values, failed) = src.read();
let m: serde_json::Map<String, Value> = values.into_iter().map(|(k, v)| (k.to_string(), v)).collect();
println!("{}", serde_json::to_string_pretty(&json!({ "signals": m, "failed": failed })).unwrap());
return ExitCode::SUCCESS;
}
let Some((host, port)) = broker.rsplit_once(':').and_then(|(h, p)| Some((h.to_string(), p.parse::<u16>().ok()?)))
else {
eprintln!("orin-status: --broker must be HOST:PORT\n{USAGE}");
return ExitCode::from(2);
};
let mut opts = MqttOptions::new("orin-status", host, port);
opts.set_keep_alive(Duration::from_secs(5));
opts.set_last_will(LastWill::new(
SYSTEM_TOPIC,
json!({ "state": "stopping", "detail": "connection lost" }).to_string(),
QoS::AtLeastOnce,
true,
));
let (client, mut connection) = Client::new(opts, 256);
// The event loop runs on its own thread and reconnects forever; publishing never waits on it.
let connected = Arc::new(AtomicBool::new(false));
let announce = Arc::new(AtomicBool::new(false));
{
let (connected, announce) = (connected.clone(), announce.clone());
thread::spawn(move || {
for ev in connection.iter() {
match ev {
Ok(Event::Incoming(Packet::ConnAck(_))) => {
eprintln!("orin-status: connected to the broker");
connected.store(true, Ordering::Relaxed);
announce.store(true, Ordering::Relaxed);
}
Ok(_) => {}
Err(e) => {
if connected.swap(false, Ordering::Relaxed) {
eprintln!("orin-status: broker: {e}; retrying");
}
thread::sleep(Duration::from_secs(1));
}
}
}
});
}
eprintln!("orin-status {} publishing to {broker}", env!("CARGO_PKG_VERSION"));
let mut last_state = String::new();
let mut last_announce = Instant::now();
loop {
let tick = Instant::now();
let (values, failed) = src.read();
if connected.load(Ordering::Relaxed) {
for (name, value) in values {
let unit = units.get(name).unwrap_or_else(|| panic!("signal {name} is not in contracts signals.json"));
let env = json!({ "timestamp": now_us(), "monotonic": mono_ns(), "value": value, "unit": unit });
// try_publish: a full queue drops this second's value rather than stalling the loop.
let _ = client.try_publish(format!("yukart/host/{name}"), QoS::AtMostOnce, true, env.to_string());
}
let state = if failed.is_empty() {
json!({ "state": "ready" })
} else {
json!({ "state": "degraded", "detail": { "failed": failed } })
}
.to_string();
if state != last_state || announce.swap(false, Ordering::Relaxed) || last_announce.elapsed() > Duration::from_secs(60) {
if state != last_state {
eprintln!("orin-status: {state}");
}
let _ = client.try_publish(SYSTEM_TOPIC, QoS::AtLeastOnce, true, state.clone());
last_state = state;
last_announce = Instant::now();
}
}
thread::sleep(Duration::from_secs(1).saturating_sub(tick.elapsed()));
}
}
+143
View File
@@ -0,0 +1,143 @@
//! Network links: WiFi, LTE and the USB link to the tablet. Up/down and rates
//! from sysfs counters; WiFi signal from /proc/net/wireless; 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()
}
#[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,
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 },
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 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)));
}
}
}
+128
View File
@@ -0,0 +1,128 @@
//! Waveshare UPS Power Module (C): INA219 on I²C, read-only.
//!
//! Nothing is ever written to the chip. It stays at its power-on configuration
//! (32 V range, ±320 mV shunt range, continuous), so current comes from the shunt
//! voltage register over the known shunt rather than from the calibration-dependent
//! current register. The only bytes sent are register pointers.
//!
//! Charge is an estimate: Li-ion voltage maps to charge only at rest, so the
//! reading is corrected for the I·R drop across the pack and smoothed. It is
//! steadiest on input power with the pack idle, and least reliable while charging.
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::os::fd::AsRawFd;
use serde_json::{json, Value};
const I2C_SLAVE: libc::c_ulong = 0x0703;
const REG_SHUNT: u8 = 0x01; // signed, 10 µV per bit
const REG_BUS: u8 = 0x02; // bits 15..3, 4 mV per bit
const SHUNT_OHMS: f64 = 0.01; // per Waveshare's demo code (ina219.py)
const PACK_OHMS: f64 = 0.15; // 3 cells' internal resistance plus wiring; a rough figure for load correction
const CELLS: f64 = 3.0;
const IDLE_A: f64 = 0.05; // below this either way the pack is neither charging nor discharging
/// Resting voltage per cell → state of charge, typical 21700 Li-ion (3.0 V is the UPS cut-off).
const OCV: [(f64, f64); 12] = [
(3.00, 0.0),
(3.45, 5.0),
(3.68, 10.0),
(3.74, 20.0),
(3.79, 30.0),
(3.83, 40.0),
(3.87, 50.0),
(3.92, 60.0),
(3.98, 70.0),
(4.06, 80.0),
(4.13, 90.0),
(4.20, 100.0),
];
fn soc(cell_v: f64) -> f64 {
if cell_v <= OCV[0].0 {
return 0.0;
}
for w in OCV.windows(2) {
let ((v0, p0), (v1, p1)) = (w[0], w[1]);
if cell_v <= v1 {
return p0 + (p1 - p0) * (cell_v - v0) / (v1 - v0);
}
}
100.0
}
pub struct Ups {
bus: String,
addr: u16,
dev: Option<File>,
pct: Option<f64>, // smoothed
}
impl Ups {
pub fn new(bus: &str, addr: u16) -> Self {
Ups { bus: bus.to_string(), addr, dev: None, pct: None }
}
fn open(&self) -> std::io::Result<File> {
let f = OpenOptions::new().read(true).write(true).open(&self.bus)?;
if unsafe { libc::ioctl(f.as_raw_fd(), I2C_SLAVE, self.addr as libc::c_ulong) } < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(f)
}
fn reg(&mut self, r: u8) -> std::io::Result<u16> {
if self.dev.is_none() {
self.dev = Some(self.open()?);
}
let f = self.dev.as_mut().unwrap();
let mut b = [0u8; 2];
let res = f.write_all(&[r]).and_then(|_| f.read_exact(&mut b));
if res.is_err() {
self.dev = None; // reopen next time: the bus or the device may have come back
}
res.map(|_| u16::from_be_bytes(b))
}
/// false when the INA219 does not answer.
pub fn read(&mut self, out: &mut Vec<(&'static str, Value)>) -> bool {
let (Ok(shunt), Ok(bus)) = (self.reg(REG_SHUNT), self.reg(REG_BUS)) else {
return false;
};
let volts = (bus >> 3) as f64 * 0.004;
let amps = (shunt as i16) as f64 * 10e-6 / SHUNT_OHMS; // positive: charging
let rest = volts - amps * PACK_OHMS;
let pct = soc(rest / CELLS);
let smoothed = match self.pct {
Some(p) => p + 0.1 * (pct - p),
None => pct,
};
self.pct = Some(smoothed);
let state = if amps > IDLE_A {
"charging"
} else if amps < -IDLE_A {
"discharging"
} else {
"idle"
};
out.push(("ups_battery_v", json!((volts * 100.0).round() / 100.0)));
out.push(("ups_current_a", json!((amps * 1000.0).round() / 1000.0)));
out.push(("ups_battery_pct", json!(smoothed.round())));
out.push(("ups_state", json!(state)));
true
}
}
#[cfg(test)]
mod tests {
use super::soc;
#[test]
fn soc_spans_cut_off_to_full() {
assert_eq!(soc(2.9), 0.0);
assert_eq!(soc(4.25), 100.0);
assert!((soc(3.87) - 50.0).abs() < 1e-9);
assert!(soc(3.95) > 60.0 && soc(3.95) < 70.0);
}
}