mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
Add an opt-in battery indicator to the CLI and TUI status bars, shown as the first element and colour-coded by charge (green/yellow/orange/red, or green while charging). Off by default and a no-op on machines without a battery. - agent/battery.py: shared psutil-backed reader with a short TTL cache, category bucketing, and a compact 🔋/⚡ label. Fails open to "unavailable" everywhere. - CLI: /battery [on|off|status] toggle persisted to display.battery, rendered first in every status-bar width tier. - TUI: /battery slash command, config sync, a system.battery RPC polled while enabled, and a pinned first segment in StatusRule.
131 lines
3.8 KiB
Python
131 lines
3.8 KiB
Python
"""System-battery read-out for the CLI/TUI status bar.
|
|
|
|
Reads the host battery through ``psutil`` (already a Hermes dependency) and
|
|
exposes a compact, colour-coded label. Everything degrades to "unavailable"
|
|
when there is no battery (desktops, servers, VMs) or when the read fails, so
|
|
callers can render the result unconditionally and simply show nothing.
|
|
|
|
The status bar repaints often (every keystroke and on a ~1s idle refresh), so
|
|
:func:`read_battery` memoises the last reading for a few seconds instead of
|
|
hitting ``psutil`` on every frame.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BatteryStatus:
|
|
"""A single battery reading.
|
|
|
|
``available`` is False on machines without a battery (or when the read
|
|
failed). ``percent`` is clamped to 0-100. ``plugged`` is True when on AC
|
|
power, False on battery, and None when the platform can't tell.
|
|
"""
|
|
|
|
available: bool
|
|
percent: Optional[int] = None
|
|
plugged: Optional[bool] = None
|
|
|
|
@property
|
|
def charging(self) -> bool:
|
|
return bool(self.plugged)
|
|
|
|
|
|
UNAVAILABLE = BatteryStatus(available=False)
|
|
|
|
# Colour buckets, mirroring the status-bar context styles but inverted (a full
|
|
# battery is "good", an empty one is "critical").
|
|
CATEGORY_GOOD = "good"
|
|
CATEGORY_WARN = "warn"
|
|
CATEGORY_BAD = "bad"
|
|
CATEGORY_CRITICAL = "critical"
|
|
CATEGORY_DIM = "dim"
|
|
|
|
_CACHE_TTL_SECONDS = 8.0
|
|
_cache: Optional[tuple[float, BatteryStatus]] = None
|
|
|
|
|
|
def _read_battery_uncached() -> BatteryStatus:
|
|
try:
|
|
import psutil
|
|
except Exception:
|
|
return UNAVAILABLE
|
|
|
|
# ``sensors_battery`` is missing on some platforms/builds of psutil.
|
|
reader = getattr(psutil, "sensors_battery", None)
|
|
if reader is None:
|
|
return UNAVAILABLE
|
|
|
|
try:
|
|
batt = reader()
|
|
except Exception:
|
|
return UNAVAILABLE
|
|
|
|
if batt is None:
|
|
return UNAVAILABLE
|
|
|
|
percent: Optional[int] = None
|
|
raw_percent = getattr(batt, "percent", None)
|
|
if raw_percent is not None:
|
|
try:
|
|
percent = max(0, min(100, int(round(float(raw_percent)))))
|
|
except (TypeError, ValueError):
|
|
percent = None
|
|
|
|
plugged = getattr(batt, "power_plugged", None)
|
|
if plugged is not None:
|
|
plugged = bool(plugged)
|
|
|
|
return BatteryStatus(available=True, percent=percent, plugged=plugged)
|
|
|
|
|
|
def read_battery(use_cache: bool = True) -> BatteryStatus:
|
|
"""Return the current battery status (cached for a few seconds)."""
|
|
global _cache
|
|
if use_cache and _cache is not None:
|
|
ts, cached = _cache
|
|
if time.monotonic() - ts < _CACHE_TTL_SECONDS:
|
|
return cached
|
|
|
|
status = _read_battery_uncached()
|
|
_cache = (time.monotonic(), status)
|
|
return status
|
|
|
|
|
|
def clear_cache() -> None:
|
|
"""Drop the memoised reading (used by tests)."""
|
|
global _cache
|
|
_cache = None
|
|
|
|
|
|
def battery_category(status: BatteryStatus) -> str:
|
|
"""Bucket a reading into a colour category: good/warn/bad/critical/dim."""
|
|
if not status.available or status.percent is None:
|
|
return CATEGORY_DIM
|
|
# On AC power the level isn't a concern — always read as healthy.
|
|
if status.charging:
|
|
return CATEGORY_GOOD
|
|
pct = status.percent
|
|
if pct <= 10:
|
|
return CATEGORY_CRITICAL
|
|
if pct <= 20:
|
|
return CATEGORY_BAD
|
|
if pct <= 50:
|
|
return CATEGORY_WARN
|
|
return CATEGORY_GOOD
|
|
|
|
|
|
def battery_glyph(status: BatteryStatus) -> str:
|
|
"""Return the leading glyph: a bolt while charging, else a battery."""
|
|
return "\u26a1" if status.charging else "\U0001f50b" # ⚡ / 🔋
|
|
|
|
|
|
def format_battery(status: BatteryStatus) -> str:
|
|
"""Return a compact label like ``🔋 82%`` / ``⚡ 82%`` (empty if N/A)."""
|
|
if not status.available or status.percent is None:
|
|
return ""
|
|
return f"{battery_glyph(status)} {status.percent}%"
|