Files
ledgrab/server/src/ledgrab/utils/metrics/android_provider.py
T
alexei.dolgolyov 546b24d015
Build Android APK / build-android (push) Failing after 1m39s
Lint & Test / test (push) Successful in 4m20s
refactor(metrics): MetricsProvider abstraction with Android /proc backend
Moves direct psutil.* calls behind a MetricsProvider Protocol so the
codebase no longer needs ad-hoc `if psutil is not None` guards at every
call site. Each provider lives in its own module under
utils/metrics/: PsutilMetricsProvider for desktop, NullMetricsProvider
as a zeroed fallback, AndroidMetricsProvider that reads /proc/stat,
/proc/meminfo, /proc/self/stat, and /proc/self/status directly (psutil
isn't available under Chaquopy). The Android provider tracks the
previous CPU sample so cpu_percent() returns delta-based percentages
matching psutil's interval=None semantics, and degrades to zeros when
any /proc file is unreadable instead of crashing the dashboard.

Factory get_metrics_provider() in utils/metrics/__init__.py picks
Android > psutil > Null. api/routes/system.py and
core/processing/metrics_history.py now go through the factory; psutil
import is confined to one place. 12 new unit tests cover paren-in-comm
parsing of /proc/self/stat, delta CPU%, missing-file resilience, and
factory selection order. Full suite: 727 passing.
2026-04-14 13:34:32 +03:00

192 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Android metrics provider — reads /proc directly (no psutil needed).
Chaquopy doesn't ship a working psutil on Android, but the kernel
exposes the same data through ``/proc``. This provider tracks the
previous sample of ``/proc/stat`` and ``/proc/self/stat`` so it can
compute CPU% deltas the same way ``psutil.cpu_percent(interval=None)``
does on desktop.
If any of the expected ``/proc`` files become unreadable (some Android
flavors lock down ``/proc/self/stat`` for non-root apps), the provider
silently falls back to zero values for the affected metric instead of
crashing the dashboard. :func:`is_supported` lets the factory decide
whether this provider is even worth instantiating on the host.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Optional
from .types import MemorySnapshot, ProcessSnapshot
def is_supported() -> bool:
"""Return True iff /proc/stat and /proc/meminfo are readable here."""
try:
with open("/proc/stat", "r"):
pass
with open("/proc/meminfo", "r"):
pass
except OSError:
return False
return True
@dataclass
class _CpuSample:
total: int
busy: int
def _read_proc_stat() -> Optional[_CpuSample]:
"""Aggregate CPU jiffies from the first ``cpu`` line of /proc/stat."""
try:
with open("/proc/stat", "r") as f:
line = f.readline()
except OSError:
return None
parts = line.split()
if not parts or parts[0] != "cpu":
return None
try:
# user nice system idle iowait irq softirq steal guest guest_nice
nums = [int(x) for x in parts[1:]]
except ValueError:
return None
if len(nums) < 4:
return None
idle = nums[3] + (nums[4] if len(nums) > 4 else 0) # idle + iowait
total = sum(nums)
return _CpuSample(total=total, busy=total - idle)
def _read_proc_self_stat_jiffies() -> Optional[int]:
"""Return user+system jiffies for the current process, or None on failure."""
try:
with open("/proc/self/stat", "rb") as f:
data = f.read()
except OSError:
return None
# The comm field (parens) can contain spaces; parse from the last ')'
end = data.rfind(b")")
if end < 0:
return None
parts = data[end + 1 :].split()
# After comm (and state), positions:
# 0=state 1=ppid 2=pgrp 3=session 4=tty_nr 5=tpgid 6=flags
# 7=minflt 8=cminflt 9=majflt 10=cmajflt 11=utime 12=stime ...
if len(parts) < 13:
return None
try:
return int(parts[11]) + int(parts[12])
except ValueError:
return None
def _read_meminfo() -> MemorySnapshot:
"""Parse /proc/meminfo into a MemorySnapshot. Zeroed on failure."""
fields: dict[str, int] = {}
try:
with open("/proc/meminfo", "r") as f:
for line in f:
key, _, rest = line.partition(":")
if not rest:
continue
val = rest.strip().split()
if not val:
continue
try:
# Values are in kB
fields[key] = int(val[0]) * 1024
except ValueError:
continue
except OSError:
return MemorySnapshot(0, 0, 0.0)
total = fields.get("MemTotal", 0)
available = fields.get("MemAvailable", fields.get("MemFree", 0))
if total <= 0:
return MemorySnapshot(0, 0, 0.0)
used = max(0, total - available)
return MemorySnapshot(
used_bytes=used,
total_bytes=total,
percent=round(used * 100.0 / total, 1),
)
def _read_self_rss_bytes() -> int:
"""Read VmRSS (resident set size) for the current process from /proc/self/status."""
try:
with open("/proc/self/status", "r") as f:
for line in f:
if line.startswith("VmRSS:"):
parts = line.split()
# "VmRSS: 12345 kB"
if len(parts) >= 2:
try:
return int(parts[1]) * 1024
except ValueError:
return 0
except OSError:
return 0
return 0
class AndroidMetricsProvider:
"""Reads CPU/RAM from /proc — used on Android via Chaquopy."""
available: bool = True
def __init__(self) -> None:
self._cpu_count = os.cpu_count() or 1
# Prime the deltas so the first real sample is meaningful.
self._last_host: Optional[_CpuSample] = _read_proc_stat()
self._last_proc_jiffies: Optional[int] = _read_proc_self_stat_jiffies()
self._last_host_total: Optional[int] = self._last_host.total if self._last_host else None
def cpu_percent(self) -> float:
sample = _read_proc_stat()
if sample is None or self._last_host is None:
self._last_host = sample
return 0.0
d_total = sample.total - self._last_host.total
d_busy = sample.busy - self._last_host.busy
self._last_host = sample
if d_total <= 0:
return 0.0
return round(d_busy * 100.0 / d_total, 1)
def cpu_count(self) -> int:
return self._cpu_count
def virtual_memory(self) -> MemorySnapshot:
return _read_meminfo()
def process_snapshot(self) -> ProcessSnapshot:
proc_jiffies = _read_proc_self_stat_jiffies()
host_sample = _read_proc_stat()
cpu = 0.0
if (
proc_jiffies is not None
and self._last_proc_jiffies is not None
and host_sample is not None
and self._last_host_total is not None
):
d_proc = proc_jiffies - self._last_proc_jiffies
d_host = host_sample.total - self._last_host_total
if d_host > 0 and d_proc >= 0:
# d_proc / d_host gives fraction of *one* core; multiply by
# cpu_count for raw N*100% scale, then normalize to 0100%.
cpu = round(d_proc * 100.0 / d_host, 1)
if proc_jiffies is not None:
self._last_proc_jiffies = proc_jiffies
if host_sample is not None:
self._last_host_total = host_sample.total
return ProcessSnapshot(cpu_percent=cpu, rss_bytes=_read_self_rss_bytes())