diff --git a/README.md b/README.md index 354560a..d80581d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/deploy/yukart-orin-status.service b/deploy/yukart-orin-status.service index b7e3f9b..acf3475 100644 --- a/deploy/yukart-orin-status.service +++ b/deploy/yukart-orin-status.service @@ -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 diff --git a/src/orin-status/main.rs b/src/orin-status/main.rs index f4a69b5..b0c1786 100644 --- a/src/orin-status/main.rs +++ b/src/orin-status/main.rs @@ -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 { .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 = 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::().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::().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 = 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 } }) diff --git a/src/orin-status/ups.rs b/src/orin-status/ups.rs index b66093a..dafc2e1 100644 --- a/src/orin-status/ups.rs +++ b/src/orin-status/ups.rs @@ -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() {