orin-status: clean poweroff when the UPS pack stays low while discharging
/ crosscheck (push) Successful in 0s
Rust (L4T) / Clippy, test, build, glibc (push) Successful in 14s

--poweroff-below V --poweroff-after N (deployed: 11.0 V, 30 readings at 1/s).
Charging or a missing reading restarts the count. Publishes 'stopping' first;
poweroff through svc-gw's polkit grant, no sudo.

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-14 08:32:17 +09:00
parent 90d011f3a4
commit f77676f1e2
4 changed files with 112 additions and 14 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ needed.
| `obd-poller` | `yukart-obd-poller.service` | OBD-II mode 01 on the K-line through the Bluetooth ELM327 (address in `/etc/default/yukart-elm`), the single writer to the car; never on CAN. The `obd_*` signals, calibration references (`yukart/vehicle/*`): rpm, speed, throttle, load, MAF, timing, O2 every round; temperatures and trims every 5 s. Lets go of the adapter after a minute of ECU silence |
| `gnss` | `yukart-gnss.service` | gpsd's fix as single values on `yukart/gnss/*`: fix, speed, heading, position, altitude, satellites |
| `imu` | `yukart-imu.service` | The WT61 (plus) on any USB-UART adapter, found by itself (adapter and speed), turned into the car's frame (`--forward`, `--right`): roll, pitch, gravity-free longitudinal and lateral g, yaw rate on `yukart/imu/*`, at most every 40 ms. Never writes to the module |
| `orin-status` | `yukart-orin-status.service` | The Orin's health on the bus, once a second: temperatures, CPU/GPU load, memory, power, fan, WiFi (with SSID)/LTE/tablet links, the CAN interface, the ELM's Bluetooth link, UPS battery (`yukart/host/*`) |
| `orin-status` | `yukart-orin-status.service` | The Orin's health on the bus, once a second: temperatures, CPU/GPU load, memory, power, fan, WiFi (with SSID)/LTE/tablet links, the CAN interface, the ELM's Bluetooth link, UPS battery (`yukart/host/*`). Powers the Orin off cleanly when the UPS pack stays below 11.0 V while discharging for 30 s |
```bash
scripts/deploy-recorder.sh # build, glibc check, install on the car Orin
+3 -1
View File
@@ -11,7 +11,9 @@ User=mikkeli
SupplementaryGroups=i2c
# The ELM's address, for elm_connected; without the file nothing is reported.
EnvironmentFile=-/etc/default/yukart-elm
ExecStart=/usr/local/bin/yukart-orin-status --elm ${ELM_ADDR}
# Below 11.0 V while discharging for 30 s (about 10 % left at the Orin's load): power off
# cleanly, through the polkit grant svc-gw installs (50-yukart-gw-power.pkla), not sudo.
ExecStart=/usr/local/bin/yukart-orin-status --elm ${ELM_ADDR} --poweroff-below 11.0 --poweroff-after 30
Restart=always
RestartSec=2
+62 -7
View File
@@ -32,6 +32,9 @@ usage: orin-status [options]
--elm ADDR OBD-II adapter's Bluetooth address to report; '' for none ('')
--i2c DEV I²C bus of the UPS INA219 (/dev/i2c-7)
--ups-addr ADDR INA219 address, hex (41)
--poweroff-below V power the Orin off cleanly once the UPS pack reads below V while
discharging, for --poweroff-after readings in a row (off by default)
--poweroff-after N readings, one a second (30)
--once print one reading as JSON and exit (no broker)";
const REGISTRY: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../contracts/schema/signals.json"));
@@ -59,6 +62,9 @@ fn units() -> std::collections::HashMap<String, String> {
.collect()
}
/// Signal values, failed sources, and the UPS pack's (volts, amps) when it answered.
type Reading = (Vec<(&'static str, Value)>, Vec<&'static str>, Option<(f64, f64)>);
struct Sources {
host: host::Host,
net: net::Net,
@@ -68,18 +74,19 @@ struct Sources {
}
impl Sources {
/// One reading of everything, and the names of sources that failed.
fn read(&mut self) -> (Vec<(&'static str, Value)>, Vec<&'static str>) {
/// One reading of everything, the names of sources that failed, and the pack's (volts, amps).
fn read(&mut self) -> Reading {
let mut out = Vec::new();
let mut failed = Vec::new();
self.host.read(&mut out);
self.net.read(&mut out);
self.can.read(&mut out);
self.elm.read(&mut out);
if !self.ups.read(&mut out) {
let pack = self.ups.read(&mut out);
if pack.is_none() {
failed.push("ups");
}
(out, failed)
(out, failed, pack)
}
}
@@ -90,6 +97,8 @@ fn main() -> ExitCode {
let mut elm_addr = String::new();
let mut addr: u16 = 0x41;
let mut once = false;
let mut poweroff_below: Option<f64> = None;
let mut poweroff_after: u32 = 30;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
@@ -98,6 +107,20 @@ fn main() -> ExitCode {
"--i2c" => i2c = args.next().unwrap_or_default(),
"--can" => can_iface = args.next().unwrap_or_default(),
"--elm" => elm_addr = args.next().unwrap_or_default(),
"--poweroff-below" => match args.next().and_then(|v| v.parse::<f64>().ok()) {
Some(v) => poweroff_below = Some(v),
None => {
eprintln!("orin-status: --poweroff-below needs volts\n{USAGE}");
return ExitCode::from(2);
}
},
"--poweroff-after" => match args.next().and_then(|v| v.parse::<u32>().ok()).filter(|&n| n > 0) {
Some(n) => poweroff_after = n,
None => {
eprintln!("orin-status: --poweroff-after needs a count\n{USAGE}");
return ExitCode::from(2);
}
},
"--ups-addr" => match args.next().and_then(|v| u16::from_str_radix(v.trim_start_matches("0x"), 16).ok()) {
Some(v) => addr = v,
None => {
@@ -118,7 +141,7 @@ fn main() -> ExitCode {
if once {
src.read(); // rates and CPU load need two readings
thread::sleep(Duration::from_secs(1));
let (values, failed) = src.read();
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;
@@ -165,11 +188,39 @@ fn main() -> ExitCode {
}
eprintln!("orin-status {} publishing to {broker}", env!("CARGO_PKG_VERSION"));
let mut low = poweroff_below.map(|v| {
eprintln!("orin-status: powers off below {v} V while discharging, after {poweroff_after} readings in a row");
ups::LowBattery::new(v, poweroff_after)
});
let mut powering_off = false;
let mut last_state = String::new();
let mut last_announce = Instant::now();
loop {
let tick = Instant::now();
let (values, failed) = src.read();
let (values, failed, pack) = src.read();
// The low-battery rule: shut down cleanly (sessions closed, filesystems synced)
// rather than let the pack cut the power. Unprivileged, through the same polkit
// grant as svc-gw's poweroff (svc-gw deploy/50-yukart-gw-power.pkla).
let mut battery_low = None;
if let Some(l) = low.as_mut() {
let fire = l.sample(pack);
if l.run() > 0 {
battery_low = Some(format!("{:.2} V, below {} V for {}/{} s", pack.map_or(0.0, |p| p.0), l.floor_v, l.run(), l.samples));
}
if fire && !powering_off {
powering_off = true;
eprintln!("orin-status: battery low ({}): powering off", battery_low.as_deref().unwrap_or(""));
thread::spawn(|| {
// Two seconds for the state below to reach the broker.
thread::sleep(Duration::from_secs(2));
match std::process::Command::new("systemctl").args(["poweroff", "--no-ask-password"]).status() {
Ok(s) if s.success() => {}
Ok(s) => eprintln!("orin-status: systemctl poweroff exited {s}"),
Err(e) => eprintln!("orin-status: systemctl poweroff: {e}"),
}
});
}
}
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"));
@@ -177,7 +228,11 @@ fn main() -> ExitCode {
// 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() {
let state = if powering_off {
json!({ "state": "stopping", "detail": format!("battery low: powering off ({})", battery_low.as_deref().unwrap_or("")) })
} else if let Some(b) = &battery_low {
json!({ "state": "degraded", "detail": { "battery_low": b, "failed": failed } })
} else if failed.is_empty() {
json!({ "state": "ready" })
} else {
json!({ "state": "degraded", "detail": { "failed": failed } })
+46 -5
View File
@@ -85,10 +85,10 @@ impl Ups {
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 {
/// The pack's voltage and current (positive: charging), or None when the INA219 does not answer.
pub fn read(&mut self, out: &mut Vec<(&'static str, Value)>) -> Option<(f64, f64)> {
let (Ok(shunt), Ok(bus)) = (self.reg(REG_SHUNT), self.reg(REG_BUS)) else {
return false;
return None;
};
let volts = (bus >> 3) as f64 * 0.004;
let amps = (shunt as i16) as f64 * 10e-6 / SHUNT_OHMS; // positive: charging
@@ -110,13 +110,54 @@ impl Ups {
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
Some((volts, amps))
}
}
/// Shut down before the pack runs flat: the battery below `floor_v` and discharging
/// (not charging) for `samples` readings in a row. A missing reading is no evidence
/// and starts the count again, so a flaky I²C bus can never trigger it.
pub struct LowBattery {
pub floor_v: f64,
pub samples: u32,
run: u32,
}
impl LowBattery {
pub fn new(floor_v: f64, samples: u32) -> Self {
LowBattery { floor_v, samples, run: 0 }
}
/// One reading (volts, amps; amps positive charging). True once the run is long enough.
pub fn sample(&mut self, reading: Option<(f64, f64)>) -> bool {
match reading {
Some((v, a)) if v < self.floor_v && a < -IDLE_A => self.run += 1,
_ => self.run = 0,
}
self.run >= self.samples
}
/// Readings in a row so far below the floor while discharging.
pub fn run(&self) -> u32 {
self.run
}
}
#[cfg(test)]
mod tests {
use super::soc;
use super::{soc, LowBattery};
#[test]
fn low_battery_needs_a_run_of_discharging_readings() {
let mut l = LowBattery::new(11.0, 3);
let low = Some((10.9, -0.45));
assert!(!l.sample(low) && !l.sample(low));
assert!(!l.sample(Some((10.9, 1.4)))); // charging: not low, the run restarts
assert!(!l.sample(low) && !l.sample(None)); // no reading: no evidence, restarts too
assert!(!l.sample(Some((11.2, -0.45)))); // above the floor
assert!(!l.sample(low) && !l.sample(low));
assert!(l.sample(low));
}
#[test]
fn soc_spans_cut_off_to_full() {