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:
Generated
+212
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
Executable
+42
@@ -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'
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user