diff --git a/TODO.md b/TODO.md index b8c02d4..4584cf8 100644 --- a/TODO.md +++ b/TODO.md @@ -77,6 +77,6 @@ Drive USB LED controllers (APA102, WS2812) connected directly to the Android TV Beyond the `/proc`-based AndroidMetricsProvider that's now in place: -- [ ] Optional: app-specific memory via `Debug.getMemoryInfo()` through a Kotlin → Python Chaquopy bridge (more accurate than `VmRSS` for split-app-process accounting) -- [ ] Consider: device battery/temperature readings for TV boxes (some have thermal throttling) -- [ ] Optional: GPU usage via `/sys/class/kgsl/kgsl-3d0/gpubusy` on Adreno, Mali-specific paths for Mali GPUs +- [x] Device battery + thermal-zone readings (`/sys/class/power_supply/battery/{capacity,temp}`, `/sys/class/thermal/thermal_zone*/temp` filtered by zone type). Surfaced through `MetricsProvider.thermals()`, `PerformanceResponse.{cpu_temp_c,battery_percent,battery_temp_c}`, the metrics-history snapshot, and a new dashboard temperature chart that hides itself when the backend reports null. GPU card now hides (no "unavailable" placeholder) when no GPU is present. +- [WONTDO] Optional: app-specific memory via `Debug.getMemoryInfo()` through a Kotlin → Python Chaquopy bridge (more accurate than `VmRSS` for split-app-process accounting) +- [WONTDO] Optional: GPU usage via `/sys/class/kgsl/kgsl-3d0/gpubusy` on Adreno, Mali-specific paths for Mali GPUs diff --git a/server/src/ledgrab/api/routes/system.py b/server/src/ledgrab/api/routes/system.py index 7468691..a38269f 100644 --- a/server/src/ledgrab/api/routes/system.py +++ b/server/src/ledgrab/api/routes/system.py @@ -273,6 +273,7 @@ def get_system_performance(_: AuthRequired): metrics = get_metrics_provider() mem = metrics.virtual_memory() proc = metrics.process_snapshot() + thermals = metrics.thermals() app_ram_mb = round(proc.rss_bytes / 1024 / 1024, 1) gpu = None @@ -313,6 +314,9 @@ def get_system_performance(_: AuthRequired): app_cpu_percent=proc.cpu_percent, app_ram_mb=app_ram_mb, gpu=gpu, + battery_percent=thermals.battery_percent, + battery_temp_c=thermals.battery_temp_c, + cpu_temp_c=thermals.cpu_temp_c, timestamp=datetime.now(timezone.utc), ) diff --git a/server/src/ledgrab/api/schemas/system.py b/server/src/ledgrab/api/schemas/system.py index 212107c..f373236 100644 --- a/server/src/ledgrab/api/schemas/system.py +++ b/server/src/ledgrab/api/schemas/system.py @@ -80,6 +80,16 @@ class PerformanceResponse(BaseModel): app_cpu_percent: float = Field(description="App process CPU usage percent") app_ram_mb: float = Field(description="App process resident memory in MB") gpu: GpuInfo | None = Field(default=None, description="GPU info (null if unavailable)") + battery_percent: float | None = Field( + default=None, description="Battery charge percent (null if no battery)" + ) + battery_temp_c: float | None = Field( + default=None, description="Battery temperature in °C (null if unsupported)" + ) + cpu_temp_c: float | None = Field( + default=None, + description="Hottest CPU/SoC thermal zone in °C (null if unsupported)", + ) timestamp: datetime = Field(description="Measurement timestamp") diff --git a/server/src/ledgrab/core/processing/metrics_history.py b/server/src/ledgrab/core/processing/metrics_history.py index 72d272a..0d950a1 100644 --- a/server/src/ledgrab/core/processing/metrics_history.py +++ b/server/src/ledgrab/core/processing/metrics_history.py @@ -28,6 +28,7 @@ def _collect_system_snapshot() -> dict: metrics = get_metrics_provider() mem = metrics.virtual_memory() proc = metrics.process_snapshot() + thermals = metrics.thermals() snapshot = { "t": datetime.now(timezone.utc).isoformat(), "cpu": metrics.cpu_percent(), @@ -39,6 +40,9 @@ def _collect_system_snapshot() -> dict: "gpu_util": None, "gpu_temp": None, "app_gpu_mem": None, + "cpu_temp": thermals.cpu_temp_c, + "battery_pct": thermals.battery_percent, + "battery_temp": thermals.battery_temp_c, } try: diff --git a/server/src/ledgrab/static/js/features/perf-charts.ts b/server/src/ledgrab/static/js/features/perf-charts.ts index 1c96261..a73c4ef 100644 --- a/server/src/ledgrab/static/js/features/perf-charts.ts +++ b/server/src/ledgrab/static/js/features/perf-charts.ts @@ -15,14 +15,18 @@ import { isActiveTab } from '../core/tab-registry.ts'; import { createColorPicker, registerColorPicker } from '../core/color-picker.ts'; const MAX_SAMPLES = 120; -const CHART_KEYS = ['cpu', 'ram', 'gpu']; +const CHART_KEYS = ['cpu', 'ram', 'gpu', 'temp']; const PERF_MODE_KEY = 'perfMetricsMode'; +/** Metrics that don't have a per-process variant (host-only). */ +const HOST_ONLY_KEYS = new Set(['temp']); + /** Default accent colors per metric — distinct hues for visual identity. */ const METRIC_COLORS: Record = { cpu: '#FF6B6B', // warm coral ram: '#A855F7', // electric violet gpu: '#10B981', // emerald teal + temp: '#FCD34D', // amber / heat }; /** Complementary app/process line colors — clearly different hue per metric. */ @@ -35,10 +39,11 @@ const APP_COLORS: Record = { type PerfMode = 'system' | 'app' | 'both'; let _pollTimer: ReturnType | null = null; -let _charts: Record = {}; // { cpu: Chart, ram: Chart, gpu: Chart } -let _history: Record = { cpu: [], ram: [], gpu: [] }; -let _appHistory: Record = { cpu: [], ram: [], gpu: [] }; +let _charts: Record = {}; // { cpu, ram, gpu, temp } +let _history: Record = { cpu: [], ram: [], gpu: [], temp: [] }; +let _appHistory: Record = { cpu: [], ram: [], gpu: [], temp: [] }; let _hasGpu: boolean | null = null; // null = unknown, true/false after first fetch +let _hasTemp: boolean | null = null; // null = unknown, true/false after first fetch let _mode: PerfMode = (localStorage.getItem(PERF_MODE_KEY) as PerfMode) || 'both'; function _getColor(key: string): string { @@ -103,7 +108,8 @@ export function setPerfMode(mode: PerfMode): void { const showSystem = mode === 'system' || mode === 'both'; const showApp = mode === 'app' || mode === 'both'; chart.data.datasets[0].hidden = !showSystem; - chart.data.datasets[1].hidden = !showApp; + // Host-only metrics never have an app dataset to show. + chart.data.datasets[1].hidden = HOST_ONLY_KEYS.has(key) ? true : !showApp; chart.update('none'); } } @@ -137,6 +143,13 @@ export function renderPerfSection(): string {
+ `; } @@ -145,8 +158,9 @@ function _createChart(canvasId: string, key: string): any { if (!ctx) return null; const color = _getColor(key); const appColor = _getAppColor(key); + const isHostOnly = HOST_ONLY_KEYS.has(key); const showSystem = _mode === 'system' || _mode === 'both'; - const showApp = _mode === 'app' || _mode === 'both'; + const showApp = !isHostOnly && (_mode === 'app' || _mode === 'both'); return new Chart(ctx, { type: 'line', data: { @@ -200,13 +214,27 @@ async function _seedFromServer(): Promise { _history.cpu = samples.map((s: any) => s.cpu).filter((v: any) => v != null); _history.ram = samples.map((s: any) => s.ram_pct).filter((v: any) => v != null); _history.gpu = samples.map((s: any) => s.gpu_util).filter((v: any) => v != null); + _history.temp = samples.map((s: any) => s.cpu_temp).filter((v: any) => v != null); _appHistory.cpu = samples.map((s: any) => s.app_cpu).filter((v: any) => v != null); _appHistory.ram = samples.map((s: any) => s.app_ram).filter((v: any) => v != null); _appHistory.gpu = samples.map((s: any) => s.app_gpu_mem).filter((v: any) => v != null); - // Detect GPU availability from history + // Detect GPU availability from history. Only conclude "no GPU" when + // we actually have samples — an empty history shouldn't hide the + // card prematurely. if (_history.gpu.length > 0) { _hasGpu = true; + } else if (samples.length > 0) { + _hasGpu = false; + const card = document.getElementById('perf-gpu-card'); + if (card) card.setAttribute('hidden', ''); + } + // Detect temperature availability from history; reveal the card now + // so the user doesn't see it appear/disappear after the first poll. + if (_history.temp.length > 0) { + _hasTemp = true; + const card = document.getElementById('perf-temp-card'); + if (card) card.removeAttribute('hidden'); } for (const key of CHART_KEYS) { @@ -236,6 +264,7 @@ export async function initPerfCharts(): Promise { _charts.cpu = _createChart('perf-chart-cpu', 'cpu'); _charts.ram = _createChart('perf-chart-ram', 'ram'); _charts.gpu = _createChart('perf-chart-gpu', 'gpu'); + _charts.temp = _createChart('perf-chart-temp', 'temp'); await _seedFromServer(); } @@ -331,6 +360,27 @@ async function _fetchPerformance(): Promise { )); } + // Temperature (host-only, no app variant) + if (data.cpu_temp_c != null) { + if (_hasTemp !== true) { + _hasTemp = true; + const card = document.getElementById('perf-temp-card'); + if (card) card.removeAttribute('hidden'); + } + _pushSample('temp', data.cpu_temp_c, null); + const tempEl = document.getElementById('perf-temp-value'); + if (tempEl) { + let display = `${data.cpu_temp_c.toFixed(0)}°C`; + if (data.battery_temp_c != null) { + display += ` bat ${data.battery_temp_c.toFixed(0)}°C`; + } + tempEl.innerHTML = display; + } + } else if (_hasTemp === null) { + // No temp data on first poll → backend doesn't expose it; keep card hidden. + _hasTemp = false; + } + // GPU if (data.gpu) { _hasGpu = true; @@ -353,16 +403,10 @@ async function _fetchPerformance(): Promise { if (nameEl && !nameEl.textContent) nameEl.textContent = data.gpu.name; } } else if (_hasGpu === null) { + // No GPU info on first poll → backend doesn't expose it; hide the card. _hasGpu = false; const card = document.getElementById('perf-gpu-card'); - if (card) { - const canvas = card.querySelector('canvas'); - if (canvas) canvas.style.display = 'none'; - const noGpu = document.createElement('div'); - noGpu.className = 'perf-chart-unavailable'; - noGpu.textContent = t('dashboard.perf.unavailable'); - card.appendChild(noGpu); - } + if (card) card.setAttribute('hidden', ''); } } catch { // Silently ignore fetch errors (e.g., network issues, tab hidden) diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index f035319..ea26554 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -765,6 +765,7 @@ "dashboard.perf.cpu": "CPU", "dashboard.perf.ram": "RAM", "dashboard.perf.gpu": "GPU", + "dashboard.perf.temp": "Temperature", "dashboard.perf.unavailable": "unavailable", "dashboard.perf.color": "Chart color", "dashboard.perf.mode.system": "System", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index 671171c..f6301f1 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -746,6 +746,7 @@ "dashboard.perf.cpu": "ЦП", "dashboard.perf.ram": "ОЗУ", "dashboard.perf.gpu": "ГП", + "dashboard.perf.temp": "Температура", "dashboard.perf.unavailable": "недоступно", "dashboard.perf.color": "Цвет графика", "dashboard.perf.mode.system": "Система", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index cbca552..53d5cce 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -746,6 +746,7 @@ "dashboard.perf.cpu": "CPU", "dashboard.perf.ram": "内存", "dashboard.perf.gpu": "GPU", + "dashboard.perf.temp": "温度", "dashboard.perf.unavailable": "不可用", "dashboard.perf.color": "图表颜色", "dashboard.perf.mode.system": "系统", diff --git a/server/src/ledgrab/utils/metrics/__init__.py b/server/src/ledgrab/utils/metrics/__init__.py index 6c5b81b..ceaee28 100644 --- a/server/src/ledgrab/utils/metrics/__init__.py +++ b/server/src/ledgrab/utils/metrics/__init__.py @@ -20,7 +20,7 @@ from ledgrab.utils.platform import is_android from .android_provider import AndroidMetricsProvider, is_supported as _android_supported from .null_provider import NullMetricsProvider from .psutil_provider import PsutilMetricsProvider -from .types import MemorySnapshot, MetricsProvider, ProcessSnapshot +from .types import MemorySnapshot, MetricsProvider, ProcessSnapshot, ThermalSnapshot __all__ = [ "AndroidMetricsProvider", @@ -29,6 +29,7 @@ __all__ = [ "NullMetricsProvider", "ProcessSnapshot", "PsutilMetricsProvider", + "ThermalSnapshot", "get_metrics_provider", "reset_metrics_provider", ] diff --git a/server/src/ledgrab/utils/metrics/android_provider.py b/server/src/ledgrab/utils/metrics/android_provider.py index a10479e..7ac612b 100644 --- a/server/src/ledgrab/utils/metrics/android_provider.py +++ b/server/src/ledgrab/utils/metrics/android_provider.py @@ -19,7 +19,9 @@ import os from dataclasses import dataclass from typing import Optional -from .types import MemorySnapshot, ProcessSnapshot +import glob + +from .types import MemorySnapshot, ProcessSnapshot, ThermalSnapshot def is_supported() -> bool: @@ -117,6 +119,60 @@ def _read_meminfo() -> MemorySnapshot: ) +def _read_int_file(path: str) -> Optional[int]: + """Read a sysfs node holding a single integer; None on failure.""" + try: + with open(path, "r") as f: + return int(f.read().strip()) + except (OSError, ValueError): + return None + + +def _read_text_file(path: str) -> Optional[str]: + """Read a sysfs node holding a short string; None on failure.""" + try: + with open(path, "r") as f: + return f.read().strip() + except OSError: + return None + + +def _read_battery() -> tuple[Optional[float], Optional[float]]: + """Return (capacity_percent, temp_celsius). Either may be None.""" + base = "/sys/class/power_supply/battery" + capacity = _read_int_file(f"{base}/capacity") + # Battery temp is typically tenths of °C: 350 → 35.0°C. + temp_raw = _read_int_file(f"{base}/temp") + pct = float(capacity) if capacity is not None else None + temp = temp_raw / 10.0 if temp_raw is not None else None + return pct, temp + + +def _read_cpu_temp_c() -> Optional[float]: + """Hottest CPU thermal zone in °C, or None if /sys/class/thermal/* is empty. + + Walks every ``thermal_zone*/temp`` (millidegrees) and returns the max. + Filters by zone type when possible to skip battery/skin sensors that + would otherwise dominate the reading. + """ + hottest: Optional[float] = None + for zone_dir in glob.glob("/sys/class/thermal/thermal_zone*"): + zone_type = (_read_text_file(f"{zone_dir}/type") or "").lower() + # Skip non-CPU zones — battery/skin/usb sensors are noise here. + if zone_type and not any(tag in zone_type for tag in ("cpu", "soc", "tsens", "core")): + continue + millideg = _read_int_file(f"{zone_dir}/temp") + if millideg is None: + continue + celsius = millideg / 1000.0 + # Sanity bound — some buggy zones report nonsense like 2147483647. + if celsius < -40.0 or celsius > 150.0: + continue + if hottest is None or celsius > hottest: + hottest = celsius + return hottest + + def _read_self_rss_bytes() -> int: """Read VmRSS (resident set size) for the current process from /proc/self/status.""" try: @@ -189,3 +245,11 @@ class AndroidMetricsProvider: self._last_host_total = host_sample.total return ProcessSnapshot(cpu_percent=cpu, rss_bytes=_read_self_rss_bytes()) + + def thermals(self) -> ThermalSnapshot: + battery_pct, battery_temp = _read_battery() + return ThermalSnapshot( + battery_percent=battery_pct, + battery_temp_c=battery_temp, + cpu_temp_c=_read_cpu_temp_c(), + ) diff --git a/server/src/ledgrab/utils/metrics/null_provider.py b/server/src/ledgrab/utils/metrics/null_provider.py index 577b42b..9ac887c 100644 --- a/server/src/ledgrab/utils/metrics/null_provider.py +++ b/server/src/ledgrab/utils/metrics/null_provider.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .types import MemorySnapshot, ProcessSnapshot +from .types import MemorySnapshot, ProcessSnapshot, ThermalSnapshot class NullMetricsProvider: @@ -26,3 +26,6 @@ class NullMetricsProvider: def process_snapshot(self) -> ProcessSnapshot: return ProcessSnapshot(cpu_percent=0.0, rss_bytes=0) + + def thermals(self) -> ThermalSnapshot: + return ThermalSnapshot() diff --git a/server/src/ledgrab/utils/metrics/psutil_provider.py b/server/src/ledgrab/utils/metrics/psutil_provider.py index f54b5b4..facaf52 100644 --- a/server/src/ledgrab/utils/metrics/psutil_provider.py +++ b/server/src/ledgrab/utils/metrics/psutil_provider.py @@ -4,7 +4,7 @@ from __future__ import annotations import os -from .types import MemorySnapshot, ProcessSnapshot +from .types import MemorySnapshot, ProcessSnapshot, ThermalSnapshot class PsutilMetricsProvider: @@ -44,3 +44,44 @@ class PsutilMetricsProvider: cpu = self._process.cpu_percent(interval=None) / self._cpu_count rss = int(self._process.memory_info().rss) return ProcessSnapshot(cpu_percent=float(cpu), rss_bytes=rss) + + def thermals(self) -> ThermalSnapshot: + battery_pct: float | None = None + battery_temp: float | None = None + cpu_temp: float | None = None + + # Battery: only some hosts report it (laptops, tablets); psutil + # raises or returns None on desktops without a battery. + sensors_battery = getattr(self._psutil, "sensors_battery", None) + if sensors_battery is not None: + try: + bat = sensors_battery() + if bat is not None: + battery_pct = float(bat.percent) + except Exception: + pass + + # CPU temperature: pick the hottest reading across all sensors. + # sensors_temperatures() is Linux-only on psutil; absent on Win/macOS. + sensors_temps = getattr(self._psutil, "sensors_temperatures", None) + if sensors_temps is not None: + try: + temps = sensors_temps() + hottest = None + for entries in (temps or {}).values(): + for entry in entries: + current = getattr(entry, "current", None) + if current is None: + continue + if hottest is None or current > hottest: + hottest = float(current) + if hottest is not None: + cpu_temp = hottest + except Exception: + pass + + return ThermalSnapshot( + battery_percent=battery_pct, + battery_temp_c=battery_temp, + cpu_temp_c=cpu_temp, + ) diff --git a/server/src/ledgrab/utils/metrics/types.py b/server/src/ledgrab/utils/metrics/types.py index c21318c..4b10f5a 100644 --- a/server/src/ledgrab/utils/metrics/types.py +++ b/server/src/ledgrab/utils/metrics/types.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Protocol +from typing import Optional, Protocol @dataclass(frozen=True) @@ -19,6 +19,21 @@ class ProcessSnapshot: rss_bytes: int +@dataclass(frozen=True) +class ThermalSnapshot: + """Battery + thermal readings; every field is optional. + + Different platforms expose different subsets of these — desktops + rarely have a battery temp, headless servers rarely report any + thermal zone, and stock Android often locks down everything except + the battery node. ``None`` means "not available", *not* "zero". + """ + + battery_percent: Optional[float] = None + battery_temp_c: Optional[float] = None + cpu_temp_c: Optional[float] = None # max across all thermal zones + + class MetricsProvider(Protocol): """Read-only host + current-process metrics.""" @@ -28,3 +43,4 @@ class MetricsProvider(Protocol): def cpu_count(self) -> int: ... def virtual_memory(self) -> MemorySnapshot: ... def process_snapshot(self) -> ProcessSnapshot: ... + def thermals(self) -> ThermalSnapshot: ... diff --git a/server/tests/test_metrics_provider.py b/server/tests/test_metrics_provider.py index 16cacd0..bb9e25e 100644 --- a/server/tests/test_metrics_provider.py +++ b/server/tests/test_metrics_provider.py @@ -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