Files
hermes-agent/agent/battery.py

103 lines
3.3 KiB
Python

"""System-battery read-out for the CLI/TUI status bar.
Reads the host battery through ``psutil`` and exposes a compact, colour-coded label. Everything
degrades to "unavailable" (no battery / read failure) so callers can render unconditionally. The
status bar repaints on every keystroke, so :func:`read_battery` memoises the reading for a few seconds.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class BatteryStatus:
"""One reading: ``percent`` clamped 0-100; ``plugged`` 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 (full battery = "good").
CATEGORY_GOOD = "good"
CATEGORY_WARN = "warn"
CATEGORY_BAD = "bad"
CATEGORY_CRITICAL = "critical"
CATEGORY_DIM = "dim"
# (upper bound inclusive, category) for a discharging battery; first match wins.
_LEVEL_CATEGORIES = ((10, CATEGORY_CRITICAL), (20, CATEGORY_BAD), (50, CATEGORY_WARN))
_CACHE_TTL_SECONDS = 8.0
_cache: Optional[tuple[float, BatteryStatus]] = None
def _read_battery_uncached() -> BatteryStatus:
try:
import psutil
# ``sensors_battery`` is missing on some platforms/builds of psutil.
batt = getattr(psutil, "sensors_battery")()
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)
return BatteryStatus(available=True, percent=percent, plugged=None if plugged is None else bool(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 and time.monotonic() - _cache[0] < _CACHE_TTL_SECONDS:
return _cache[1]
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
if status.charging: # on AC power the level isn't a concern
return CATEGORY_GOOD
for bound, category in _LEVEL_CATEGORIES:
if status.percent <= bound:
return category
return CATEGORY_GOOD
def battery_glyph(status: BatteryStatus) -> str:
"""Leading glyph: a bolt while charging, else a battery."""
return "\u26a1" if status.charging else "\U0001f50b" # ⚡ / 🔋
def format_battery(status: BatteryStatus) -> str:
"""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}%"