//! # terminal-pet //! //! The second face: a pet whose mood follows the shell and the machine. //! //! The garden renders the four git aggregates. Everything else on the bus — //! the shell's keystroke rate and session length, the five hardware scalars — //! had no reader at all, which meant the collectors producing them could stop //! without anything visibly changing. This is their reader. //! //! It is also where the daemon's own health becomes visible. //! [`SignalName::CollectorUp`] at `0.0` makes the pet sick, and that outranks //! every other mood: a pet that cannot feel its own hardware should look wrong //! rather than look calm. //! //! Stateless by design — [`PetState`] is a pure function of the current //! snapshot, so there is no persistence to corrupt and nothing to migrate. use std::collections::BTreeMap; use signal_schema::{Signal, SignalName, Source}; /// A signal older than this is treated as stale: the collector may still be up /// while the value behind it stopped moving. Comfortably past the daemon's 2s /// default tick, so a healthy system never flickers. pub const STALE_AFTER_MS: u64 = 30_000; /// Keystrokes per minute at or above which the pet reads as busy. pub const BUSY_KEYS_PER_MIN: f64 = 60.0; /// `ProcessInfo` thermal state at or above which the pet reads as overheating. pub const HOT_THERMAL_STATE: f64 = 2.0; /// Battery percentage below which an unplugged machine reads as flagging. pub const LOW_BATTERY_PCT: f64 = 20.0; /// One metric as the pet sees it. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Reading { pub value: f64, /// The value has not been refreshed within [`STALE_AFTER_MS`]. Rendered /// dimmed: "up but stalled" is not the same as "down". pub stale: bool, } /// What the pet is doing, in precedence order. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mood { /// A collector is down. Outranks everything else. Sick, Overheating, LowBattery, Busy, Calm, Sleeping, } impl Mood { pub fn face(self) -> &'static str { match self { Mood::Sick => "(x_x)", Mood::Overheating => "(>_<)", Mood::LowBattery => "(-_-)", Mood::Busy => "(o_o)", Mood::Calm => "(^_^)", Mood::Sleeping => "(-.-)", } } pub fn label(self) -> &'static str { match self { Mood::Sick => "sick", Mood::Overheating => "overheating", Mood::LowBattery => "flagging", Mood::Busy => "busy", Mood::Calm => "calm", Mood::Sleeping => "asleep", } } } /// The pet, derived from one snapshot. #[derive(Debug, Clone, PartialEq)] pub struct PetState { pub mood: Mood, /// Collectors reporting down, in wire order. pub down: Vec, pub keys_per_min: Option, pub session_seconds: Option, pub cpu_load: Option, pub thermal_state: Option, pub battery_pct: Option, pub charging: Option, pub battery_draw_w: Option, } /// Fold a snapshot into the pet. /// /// `now_ms` is passed rather than read so the staleness rule is testable. pub fn pet_from_signals(signals: &[Signal], now_ms: u64) -> PetState { let mut latest: BTreeMap<(u8, u8), &Signal> = BTreeMap::new(); for s in signals { latest.insert((s.name.to_u8(), s.source.to_u8()), s); } let read = |name: SignalName| -> Option { latest .iter() .find(|((n, _), _)| *n == name.to_u8()) .map(|(_, s)| Reading { value: s.value.0, stale: now_ms.saturating_sub(s.ts) > STALE_AFTER_MS, }) }; let mut down: Vec = latest .iter() .filter(|((n, _), s)| *n == SignalName::CollectorUp.to_u8() && s.value.0 == 0.0) .filter_map(|((_, src), _)| Source::from_u8(*src)) .collect(); down.sort_by_key(|s| s.to_u8()); let keys_per_min = read(SignalName::KeysPerMin); let thermal_state = read(SignalName::ThermalState); let battery_pct = read(SignalName::BatteryPct); let charging = read(SignalName::Charging); let plugged_in = charging.map(|c| c.value >= 1.0).unwrap_or(false); let mood = if !down.is_empty() { Mood::Sick } else if thermal_state.map(|t| t.value >= HOT_THERMAL_STATE).unwrap_or(false) { Mood::Overheating } else if battery_pct.map(|b| b.value < LOW_BATTERY_PCT).unwrap_or(false) && !plugged_in { Mood::LowBattery } else { match keys_per_min.map(|k| k.value) { Some(k) if k >= BUSY_KEYS_PER_MIN => Mood::Busy, Some(k) if k > 0.0 => Mood::Calm, _ => Mood::Sleeping, } }; PetState { mood, down, keys_per_min, session_seconds: read(SignalName::SessionSeconds), cpu_load: read(SignalName::CpuLoad), thermal_state, battery_pct, charging, battery_draw_w: read(SignalName::BatteryDrawW), } } /// Render the pet as a text block. pub fn render(state: &PetState) -> String { let mut out = String::from("terminal-pet\n============\n\n"); out.push_str(&format!(" {} {}\n\n", state.mood.face(), state.mood.label())); let row = |label: &str, reading: Option, unit: &str| -> String { match reading { None => format!(" {label:<16} —\n"), Some(r) => { let mark = if r.stale { " (stale)" } else { "" }; format!(" {label:<16} {:.2}{unit}{mark}\n", r.value) } } }; out.push_str(&row("keys/min", state.keys_per_min, "")); out.push_str(&row("session", state.session_seconds, "s")); out.push_str(&row("cpu load", state.cpu_load, "")); out.push_str(&row("thermal", state.thermal_state, "")); out.push_str(&row("battery", state.battery_pct, "%")); out.push_str(&row("draw", state.battery_draw_w, "W")); if !state.down.is_empty() { out.push_str("\n collectors down:"); for source in &state.down { out.push_str(&format!(" {source:?}")); } out.push('\n'); } out } #[cfg(test)] mod tests { use super::*; use signal_schema::{Value, SCHEMA_VERSION}; fn sig(source: Source, name: SignalName, value: f64, ts: u64) -> Signal { Signal { schema_version: SCHEMA_VERSION, ts, source, name, value: Value(value), tag: None, } } const NOW: u64 = 1_000_000; fn typing(keys: f64) -> Signal { sig(Source::Terminal, SignalName::KeysPerMin, keys, NOW) } #[test] fn typing_drives_energy() { assert_eq!(pet_from_signals(&[typing(0.0)], NOW).mood, Mood::Sleeping); assert_eq!(pet_from_signals(&[typing(10.0)], NOW).mood, Mood::Calm); assert_eq!(pet_from_signals(&[typing(120.0)], NOW).mood, Mood::Busy); } #[test] fn no_terminal_signal_at_all_is_asleep_not_sick() { assert_eq!(pet_from_signals(&[], NOW).mood, Mood::Sleeping); } #[test] fn heat_outranks_typing() { let signals = [ typing(120.0), sig(Source::Macos, SignalName::ThermalState, 3.0, NOW), ]; assert_eq!(pet_from_signals(&signals, NOW).mood, Mood::Overheating); } #[test] fn a_low_battery_only_counts_when_unplugged() { let low = sig(Source::Macos, SignalName::BatteryPct, 5.0, NOW); let plugged = sig(Source::Macos, SignalName::Charging, 1.0, NOW); let unplugged = sig(Source::Macos, SignalName::Charging, 0.0, NOW); assert_eq!( pet_from_signals(&[typing(1.0), low.clone(), unplugged], NOW).mood, Mood::LowBattery ); assert_eq!( pet_from_signals(&[typing(1.0), low, plugged], NOW).mood, Mood::Calm, "on the charger a low battery is not a worry" ); } /// The point of putting health on the bus: a dead collector is visible in /// the face, not just in a log nobody reads. #[test] fn a_down_collector_outranks_every_other_mood() { let signals = [ typing(120.0), sig(Source::Macos, SignalName::ThermalState, 3.0, NOW), sig(Source::Hardware, SignalName::CollectorUp, 0.0, NOW), ]; let pet = pet_from_signals(&signals, NOW); assert_eq!(pet.mood, Mood::Sick); assert_eq!(pet.down, vec![Source::Hardware]); assert!(render(&pet).contains("collectors down: Hardware")); } #[test] fn a_healthy_collector_is_not_listed_as_down() { let signals = [ typing(5.0), sig(Source::Hardware, SignalName::CollectorUp, 1.0, NOW), ]; let pet = pet_from_signals(&signals, NOW); assert_eq!(pet.mood, Mood::Calm); assert!(pet.down.is_empty()); } #[test] fn health_is_tracked_per_source() { let signals = [ sig(Source::Git, SignalName::CollectorUp, 1.0, NOW), sig(Source::Terminal, SignalName::CollectorUp, 0.0, NOW), sig(Source::Hardware, SignalName::CollectorUp, 0.0, NOW), ]; let pet = pet_from_signals(&signals, NOW); assert_eq!(pet.down, vec![Source::Terminal, Source::Hardware]); } /// Up but stalled is not the same as down: the collector never reported a /// failure, the number behind it simply stopped moving. #[test] fn an_old_value_is_stale_but_not_sick() { let old = sig( Source::Hardware, SignalName::CpuLoad, 0.5, NOW - STALE_AFTER_MS - 1, ); let pet = pet_from_signals(&[typing(5.0), old], NOW); assert!(pet.cpu_load.expect("cpu load").stale); assert!(!pet.keys_per_min.expect("keys").stale); assert_eq!(pet.mood, Mood::Calm, "stale is not sick"); assert!(render(&pet).contains("(stale)")); } }