From 0c45e680451efa7af0fa661df9a629b8b972897f Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Mon, 12 Oct 2026 16:33:31 +0900 Subject: [PATCH] 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/, once a second. Built natively on the development Orin. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz --- Cargo.lock | 212 ++++++++++++++++++++++++++++++ Cargo.toml | 7 +- deploy/yukart-orin-status.service | 17 +++ scripts/deploy-orin-status.sh | 42 ++++++ src/orin-status/host.rs | 122 +++++++++++++++++ src/orin-status/main.rs | 184 ++++++++++++++++++++++++++ src/orin-status/net.rs | 143 ++++++++++++++++++++ src/orin-status/ups.rs | 128 ++++++++++++++++++ 8 files changed, 854 insertions(+), 1 deletion(-) create mode 100644 deploy/yukart-orin-status.service create mode 100755 scripts/deploy-orin-status.sh create mode 100644 src/orin-status/host.rs create mode 100644 src/orin-status/main.rs create mode 100644 src/orin-status/net.rs create mode 100644 src/orin-status/ups.rs diff --git a/Cargo.lock b/Cargo.lock index 264cb7c..338b3e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 5da3470..27b626c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/deploy/yukart-orin-status.service b/deploy/yukart-orin-status.service new file mode 100644 index 0000000..19ee1c2 --- /dev/null +++ b/deploy/yukart-orin-status.service @@ -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 diff --git a/scripts/deploy-orin-status.sh b/scripts/deploy-orin-status.sh new file mode 100755 index 0000000..2529ab6 --- /dev/null +++ b/scripts/deploy-orin-status.sh @@ -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' diff --git a/src/orin-status/host.rs b/src/orin-status/host.rs new file mode 100644 index 0000000..34df9d7 --- /dev/null +++ b/src/orin-status/host.rs @@ -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, + power: Option<(PathBuf, PathBuf)>, // VDD_IN (millivolts, milliamps) + fan: Option, + cpu_prev: Option<(u64, u64)>, // (busy, total) jiffies +} + +fn read_num(p: &Path) -> Option { + 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 { + 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 { + 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 { + let stat = fs::read_to_string("/proc/stat").ok()?; + let f: Vec = 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 { + 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()))); + } + } +} diff --git a/src/orin-status/main.rs b/src/orin-status/main.rs new file mode 100644 index 0000000..c3c2f86 --- /dev/null +++ b/src/orin-status/main.rs @@ -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/`, 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 { + 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 = 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::().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())); + } +} diff --git a/src/orin-status/net.rs b/src/orin-status/net.rs new file mode 100644 index 0000000..bdde5e2 --- /dev/null +++ b/src/orin-status/net.rs @@ -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 { + 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() +} + +#[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, + 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 }, + 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))); + } + } +} diff --git a/src/orin-status/ups.rs b/src/orin-status/ups.rs new file mode 100644 index 0000000..b66093a --- /dev/null +++ b/src/orin-status/ups.rs @@ -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, + pct: Option, // 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 { + 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 { + 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); + } +}