103 lines
3.3 KiB
Python
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}%"
|