Ambient system companions over one privacy-preserving signal daemon (aggregate-only, no keystroke content): a git-driven terminal garden and IOKit hardware collectors. ambient daemon macos privacy terminal

crates/terminal-pet/src/lib.rs

297 lines · 10075 bytes

  1//! # terminal-pet
  2//!
  3//! The second face: a pet whose mood follows the shell and the machine.
  4//!
  5//! The garden renders the four git aggregates. Everything else on the bus —
  6//! the shell's keystroke rate and session length, the five hardware scalars —
  7//! had no reader at all, which meant the collectors producing them could stop
  8//! without anything visibly changing. This is their reader.
  9//!
 10//! It is also where the daemon's own health becomes visible.
 11//! [`SignalName::CollectorUp`] at `0.0` makes the pet sick, and that outranks
 12//! every other mood: a pet that cannot feel its own hardware should look wrong
 13//! rather than look calm.
 14//!
 15//! Stateless by design — [`PetState`] is a pure function of the current
 16//! snapshot, so there is no persistence to corrupt and nothing to migrate.
 17
 18use std::collections::BTreeMap;
 19
 20use signal_schema::{Signal, SignalName, Source};
 21
 22/// A signal older than this is treated as stale: the collector may still be up
 23/// while the value behind it stopped moving. Comfortably past the daemon's 2s
 24/// default tick, so a healthy system never flickers.
 25pub const STALE_AFTER_MS: u64 = 30_000;
 26
 27/// Keystrokes per minute at or above which the pet reads as busy.
 28pub const BUSY_KEYS_PER_MIN: f64 = 60.0;
 29/// `ProcessInfo` thermal state at or above which the pet reads as overheating.
 30pub const HOT_THERMAL_STATE: f64 = 2.0;
 31/// Battery percentage below which an unplugged machine reads as flagging.
 32pub const LOW_BATTERY_PCT: f64 = 20.0;
 33
 34/// One metric as the pet sees it.
 35#[derive(Debug, Clone, Copy, PartialEq)]
 36pub struct Reading {
 37    pub value: f64,
 38    /// The value has not been refreshed within [`STALE_AFTER_MS`]. Rendered
 39    /// dimmed: "up but stalled" is not the same as "down".
 40    pub stale: bool,
 41}
 42
 43/// What the pet is doing, in precedence order.
 44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
 45pub enum Mood {
 46    /// A collector is down. Outranks everything else.
 47    Sick,
 48    Overheating,
 49    LowBattery,
 50    Busy,
 51    Calm,
 52    Sleeping,
 53}
 54
 55impl Mood {
 56    pub fn face(self) -> &'static str {
 57        match self {
 58            Mood::Sick => "(x_x)",
 59            Mood::Overheating => "(>_<)",
 60            Mood::LowBattery => "(-_-)",
 61            Mood::Busy => "(o_o)",
 62            Mood::Calm => "(^_^)",
 63            Mood::Sleeping => "(-.-)",
 64        }
 65    }
 66
 67    pub fn label(self) -> &'static str {
 68        match self {
 69            Mood::Sick => "sick",
 70            Mood::Overheating => "overheating",
 71            Mood::LowBattery => "flagging",
 72            Mood::Busy => "busy",
 73            Mood::Calm => "calm",
 74            Mood::Sleeping => "asleep",
 75        }
 76    }
 77}
 78
 79/// The pet, derived from one snapshot.
 80#[derive(Debug, Clone, PartialEq)]
 81pub struct PetState {
 82    pub mood: Mood,
 83    /// Collectors reporting down, in wire order.
 84    pub down: Vec<Source>,
 85    pub keys_per_min: Option<Reading>,
 86    pub session_seconds: Option<Reading>,
 87    pub cpu_load: Option<Reading>,
 88    pub thermal_state: Option<Reading>,
 89    pub battery_pct: Option<Reading>,
 90    pub charging: Option<Reading>,
 91    pub battery_draw_w: Option<Reading>,
 92}
 93
 94/// Fold a snapshot into the pet.
 95///
 96/// `now_ms` is passed rather than read so the staleness rule is testable.
 97pub fn pet_from_signals(signals: &[Signal], now_ms: u64) -> PetState {
 98    let mut latest: BTreeMap<(u8, u8), &Signal> = BTreeMap::new();
 99    for s in signals {
100        latest.insert((s.name.to_u8(), s.source.to_u8()), s);
101    }
102
103    let read = |name: SignalName| -> Option<Reading> {
104        latest
105            .iter()
106            .find(|((n, _), _)| *n == name.to_u8())
107            .map(|(_, s)| Reading {
108                value: s.value.0,
109                stale: now_ms.saturating_sub(s.ts) > STALE_AFTER_MS,
110            })
111    };
112
113    let mut down: Vec<Source> = latest
114        .iter()
115        .filter(|((n, _), s)| *n == SignalName::CollectorUp.to_u8() && s.value.0 == 0.0)
116        .filter_map(|((_, src), _)| Source::from_u8(*src))
117        .collect();
118    down.sort_by_key(|s| s.to_u8());
119
120    let keys_per_min = read(SignalName::KeysPerMin);
121    let thermal_state = read(SignalName::ThermalState);
122    let battery_pct = read(SignalName::BatteryPct);
123    let charging = read(SignalName::Charging);
124
125    let plugged_in = charging.map(|c| c.value >= 1.0).unwrap_or(false);
126    let mood = if !down.is_empty() {
127        Mood::Sick
128    } else if thermal_state.map(|t| t.value >= HOT_THERMAL_STATE).unwrap_or(false) {
129        Mood::Overheating
130    } else if battery_pct.map(|b| b.value < LOW_BATTERY_PCT).unwrap_or(false) && !plugged_in {
131        Mood::LowBattery
132    } else {
133        match keys_per_min.map(|k| k.value) {
134            Some(k) if k >= BUSY_KEYS_PER_MIN => Mood::Busy,
135            Some(k) if k > 0.0 => Mood::Calm,
136            _ => Mood::Sleeping,
137        }
138    };
139
140    PetState {
141        mood,
142        down,
143        keys_per_min,
144        session_seconds: read(SignalName::SessionSeconds),
145        cpu_load: read(SignalName::CpuLoad),
146        thermal_state,
147        battery_pct,
148        charging,
149        battery_draw_w: read(SignalName::BatteryDrawW),
150    }
151}
152
153/// Render the pet as a text block.
154pub fn render(state: &PetState) -> String {
155    let mut out = String::from("terminal-pet\n============\n\n");
156    out.push_str(&format!("  {}   {}\n\n", state.mood.face(), state.mood.label()));
157
158    let row = |label: &str, reading: Option<Reading>, unit: &str| -> String {
159        match reading {
160            None => format!("  {label:<16}\n"),
161            Some(r) => {
162                let mark = if r.stale { "  (stale)" } else { "" };
163                format!("  {label:<16} {:.2}{unit}{mark}\n", r.value)
164            }
165        }
166    };
167    out.push_str(&row("keys/min", state.keys_per_min, ""));
168    out.push_str(&row("session", state.session_seconds, "s"));
169    out.push_str(&row("cpu load", state.cpu_load, ""));
170    out.push_str(&row("thermal", state.thermal_state, ""));
171    out.push_str(&row("battery", state.battery_pct, "%"));
172    out.push_str(&row("draw", state.battery_draw_w, "W"));
173
174    if !state.down.is_empty() {
175        out.push_str("\n  collectors down:");
176        for source in &state.down {
177            out.push_str(&format!(" {source:?}"));
178        }
179        out.push('\n');
180    }
181    out
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use signal_schema::{Value, SCHEMA_VERSION};
188
189    fn sig(source: Source, name: SignalName, value: f64, ts: u64) -> Signal {
190        Signal {
191            schema_version: SCHEMA_VERSION,
192            ts,
193            source,
194            name,
195            value: Value(value),
196            tag: None,
197        }
198    }
199
200    const NOW: u64 = 1_000_000;
201
202    fn typing(keys: f64) -> Signal {
203        sig(Source::Terminal, SignalName::KeysPerMin, keys, NOW)
204    }
205
206    #[test]
207    fn typing_drives_energy() {
208        assert_eq!(pet_from_signals(&[typing(0.0)], NOW).mood, Mood::Sleeping);
209        assert_eq!(pet_from_signals(&[typing(10.0)], NOW).mood, Mood::Calm);
210        assert_eq!(pet_from_signals(&[typing(120.0)], NOW).mood, Mood::Busy);
211    }
212
213    #[test]
214    fn no_terminal_signal_at_all_is_asleep_not_sick() {
215        assert_eq!(pet_from_signals(&[], NOW).mood, Mood::Sleeping);
216    }
217
218    #[test]
219    fn heat_outranks_typing() {
220        let signals = [
221            typing(120.0),
222            sig(Source::Macos, SignalName::ThermalState, 3.0, NOW),
223        ];
224        assert_eq!(pet_from_signals(&signals, NOW).mood, Mood::Overheating);
225    }
226
227    #[test]
228    fn a_low_battery_only_counts_when_unplugged() {
229        let low = sig(Source::Macos, SignalName::BatteryPct, 5.0, NOW);
230        let plugged = sig(Source::Macos, SignalName::Charging, 1.0, NOW);
231        let unplugged = sig(Source::Macos, SignalName::Charging, 0.0, NOW);
232
233        assert_eq!(
234            pet_from_signals(&[typing(1.0), low.clone(), unplugged], NOW).mood,
235            Mood::LowBattery
236        );
237        assert_eq!(
238            pet_from_signals(&[typing(1.0), low, plugged], NOW).mood,
239            Mood::Calm,
240            "on the charger a low battery is not a worry"
241        );
242    }
243
244    /// The point of putting health on the bus: a dead collector is visible in
245    /// the face, not just in a log nobody reads.
246    #[test]
247    fn a_down_collector_outranks_every_other_mood() {
248        let signals = [
249            typing(120.0),
250            sig(Source::Macos, SignalName::ThermalState, 3.0, NOW),
251            sig(Source::Hardware, SignalName::CollectorUp, 0.0, NOW),
252        ];
253        let pet = pet_from_signals(&signals, NOW);
254        assert_eq!(pet.mood, Mood::Sick);
255        assert_eq!(pet.down, vec![Source::Hardware]);
256        assert!(render(&pet).contains("collectors down: Hardware"));
257    }
258
259    #[test]
260    fn a_healthy_collector_is_not_listed_as_down() {
261        let signals = [
262            typing(5.0),
263            sig(Source::Hardware, SignalName::CollectorUp, 1.0, NOW),
264        ];
265        let pet = pet_from_signals(&signals, NOW);
266        assert_eq!(pet.mood, Mood::Calm);
267        assert!(pet.down.is_empty());
268    }
269
270    #[test]
271    fn health_is_tracked_per_source() {
272        let signals = [
273            sig(Source::Git, SignalName::CollectorUp, 1.0, NOW),
274            sig(Source::Terminal, SignalName::CollectorUp, 0.0, NOW),
275            sig(Source::Hardware, SignalName::CollectorUp, 0.0, NOW),
276        ];
277        let pet = pet_from_signals(&signals, NOW);
278        assert_eq!(pet.down, vec![Source::Terminal, Source::Hardware]);
279    }
280
281    /// Up but stalled is not the same as down: the collector never reported a
282    /// failure, the number behind it simply stopped moving.
283    #[test]
284    fn an_old_value_is_stale_but_not_sick() {
285        let old = sig(
286            Source::Hardware,
287            SignalName::CpuLoad,
288            0.5,
289            NOW - STALE_AFTER_MS - 1,
290        );
291        let pet = pet_from_signals(&[typing(5.0), old], NOW);
292        assert!(pet.cpu_load.expect("cpu load").stale);
293        assert!(!pet.keys_per_min.expect("keys").stale);
294        assert_eq!(pet.mood, Mood::Calm, "stale is not sick");
295        assert!(render(&pet).contains("(stale)"));
296    }
297}