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:
@@ -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