orin-status: clean poweroff when the UPS pack stays low while discharging
--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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user