feat(metrics): battery + thermal-zone readings with dashboard temp chart
Extends MetricsProvider with thermals() returning a ThermalSnapshot
(battery_percent, battery_temp_c, cpu_temp_c — all optional). Each
provider implements it independently:
- AndroidMetricsProvider reads /sys/class/power_supply/battery/{capacity,
temp} (battery temp is tenths of degC) and walks
/sys/class/thermal/thermal_zone*, filtering by zone type
(cpu/soc/tsens/core) so battery and skin sensors don't dominate the
reading. Rejects nonsense values like INT_MAX from buggy zones.
- PsutilMetricsProvider uses sensors_battery() and
sensors_temperatures() when present (Linux+laptops); no-ops on
Windows/macOS where psutil doesn't expose them.
- NullMetricsProvider returns the empty snapshot.
PerformanceResponse gains battery_percent / battery_temp_c / cpu_temp_c.
The metrics-history ring buffer also carries cpu_temp / battery_pct /
battery_temp per sample so the dashboard can graph them over time.
Frontend dashboard (perf-charts.ts) gets a new Temperature chart card,
hidden by default and revealed only after seed/poll confirms the
backend reports cpu_temp_c. Battery temperature shows inline as a
secondary badge. The GPU card now also hides entirely when the backend
reports gpu=null instead of showing an "unavailable" placeholder.
HOST_ONLY_KEYS prevents the System/App/Both toggle from flipping a
non-existent app dataset for temp.
Tests: 6 new for thermals (battery tenths-of-degC parsing, CPU zone
filtering, fallback when sensors absent, INT_MAX rejection); 18 metrics
tests total; full suite 733 passing.
This commit is contained in:
@@ -13,6 +13,7 @@ from ledgrab.utils.metrics import (
|
||||
NullMetricsProvider,
|
||||
ProcessSnapshot,
|
||||
PsutilMetricsProvider,
|
||||
ThermalSnapshot,
|
||||
get_metrics_provider,
|
||||
reset_metrics_provider,
|
||||
)
|
||||
@@ -181,3 +182,97 @@ def test_factory_prefers_android_when_running_on_android(monkeypatch) -> None:
|
||||
monkeypatch.setattr("ledgrab.utils.metrics._android_supported", lambda: True)
|
||||
provider = get_metrics_provider()
|
||||
assert isinstance(provider, AndroidMetricsProvider)
|
||||
|
||||
|
||||
# ── Thermals ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_null_provider_thermals_are_all_none() -> None:
|
||||
snap = NullMetricsProvider().thermals()
|
||||
assert snap == ThermalSnapshot()
|
||||
|
||||
|
||||
def test_psutil_provider_thermals_picks_hottest_sensor() -> None:
|
||||
psutil_mock = MagicMock()
|
||||
psutil_mock.Process.return_value = MagicMock()
|
||||
psutil_mock.cpu_count.return_value = 4
|
||||
bat = MagicMock(percent=78.0)
|
||||
psutil_mock.sensors_battery.return_value = bat
|
||||
psutil_mock.sensors_temperatures.return_value = {
|
||||
"coretemp": [
|
||||
MagicMock(current=55.0),
|
||||
MagicMock(current=72.5),
|
||||
],
|
||||
"acpi": [MagicMock(current=40.0)],
|
||||
}
|
||||
provider = PsutilMetricsProvider(psutil_mock)
|
||||
snap = provider.thermals()
|
||||
assert snap.battery_percent == 78.0
|
||||
assert snap.cpu_temp_c == 72.5 # hottest across all sensors
|
||||
assert snap.battery_temp_c is None # psutil doesn't expose battery temp
|
||||
|
||||
|
||||
def test_psutil_provider_thermals_handles_missing_sensors() -> None:
|
||||
psutil_mock = MagicMock()
|
||||
psutil_mock.Process.return_value = MagicMock()
|
||||
psutil_mock.cpu_count.return_value = 1
|
||||
# Strip the optional sensor methods entirely (e.g. Windows psutil).
|
||||
del psutil_mock.sensors_battery
|
||||
del psutil_mock.sensors_temperatures
|
||||
provider = PsutilMetricsProvider(psutil_mock)
|
||||
assert provider.thermals() == ThermalSnapshot()
|
||||
|
||||
|
||||
def test_android_battery_parses_tenths_of_celsius(monkeypatch) -> None:
|
||||
def _fake_int(path: str):
|
||||
return {
|
||||
"/sys/class/power_supply/battery/capacity": 78,
|
||||
"/sys/class/power_supply/battery/temp": 312, # tenths of °C → 31.2°C
|
||||
}.get(path)
|
||||
|
||||
monkeypatch.setattr(android_mod, "_read_int_file", _fake_int)
|
||||
pct, temp = android_mod._read_battery()
|
||||
assert pct == 78.0
|
||||
assert temp == 31.2
|
||||
|
||||
|
||||
def test_android_cpu_temp_filters_non_cpu_zones_and_picks_hottest(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"glob.glob",
|
||||
lambda _: [
|
||||
"/sys/class/thermal/thermal_zone0",
|
||||
"/sys/class/thermal/thermal_zone1",
|
||||
"/sys/class/thermal/thermal_zone2",
|
||||
"/sys/class/thermal/thermal_zone3",
|
||||
],
|
||||
)
|
||||
|
||||
def _fake_text(path: str):
|
||||
return {
|
||||
"/sys/class/thermal/thermal_zone0/type": "battery",
|
||||
"/sys/class/thermal/thermal_zone1/type": "cpu-thermal",
|
||||
"/sys/class/thermal/thermal_zone2/type": "soc-max",
|
||||
"/sys/class/thermal/thermal_zone3/type": "skin-therm",
|
||||
}.get(path)
|
||||
|
||||
def _fake_int(path: str):
|
||||
return {
|
||||
# Battery & skin should be filtered out by zone type
|
||||
"/sys/class/thermal/thermal_zone0/temp": 99000,
|
||||
"/sys/class/thermal/thermal_zone1/temp": 52000, # 52°C
|
||||
"/sys/class/thermal/thermal_zone2/temp": 67500, # 67.5°C ← hottest
|
||||
"/sys/class/thermal/thermal_zone3/temp": 99000,
|
||||
}.get(path)
|
||||
|
||||
monkeypatch.setattr(android_mod, "_read_text_file", _fake_text)
|
||||
monkeypatch.setattr(android_mod, "_read_int_file", _fake_int)
|
||||
|
||||
assert android_mod._read_cpu_temp_c() == 67.5
|
||||
|
||||
|
||||
def test_android_cpu_temp_rejects_nonsense_values(monkeypatch) -> None:
|
||||
monkeypatch.setattr("glob.glob", lambda _: ["/sys/class/thermal/thermal_zone0"])
|
||||
monkeypatch.setattr(android_mod, "_read_text_file", lambda _: "cpu-thermal")
|
||||
# Some buggy zones report INT_MAX
|
||||
monkeypatch.setattr(android_mod, "_read_int_file", lambda _: 2147483647)
|
||||
assert android_mod._read_cpu_temp_c() is None
|
||||
|
||||
Reference in New Issue
Block a user