move_detect/edge/health.py
유창욱 752afc3f00 feat(edge): heartbeat telemetry + monotonic timers + queue/camera robustness
- Split clocks: detector timers/cooldowns run on a MONOTONIC clock (immune to an
  NTP step on an RTC-less Pi); wall time is used only for the HMAC X-Timestamp
  and Event.occurred_at. Fixes false IMMOBILITY / dropped tracks after a clock jump.
- Heartbeat is time-based (30s, predictable regardless of load) and now carries
  temp/throttle/queue_pending/config_version telemetry.
- A dead (no-frame) camera counts as erroring instead of a healthy frame, so a
  silent monitoring outage no longer looks green.
- Offline queue: isolate one undecodable row instead of wedging every event behind
  it; classify 401 (stale clock) as transient, never a poison-drop.
- New edge/health.py: vcgencmd temp/throttle parsing, None off-Pi (no fake 0).
2026-07-25 19:20:23 +09:00

47 lines
1.9 KiB
Python

"""Pi thermal telemetry for the heartbeat.
Reports CPU temperature (C) and whether the SoC is currently throttling — the #1 cause of
Hailo inference falling off a cliff in a hot summer factory. Off-Pi (no `vcgencmd`) or on any
failure every field is None so the caller OMITS it: the dashboard must never show a fabricated
temp=0 / throttle=false that hides an overheating device.
"""
import shutil
import subprocess
def parse_temp(out: str):
"""`vcgencmd measure_temp` -> "temp=54.3'C" -> 54.3. None if unparseable."""
try:
return float(out.split("=", 1)[1].split("'", 1)[0])
except (IndexError, ValueError):
return None
def parse_throttle(out: str):
"""`vcgencmd get_throttled` -> "throttled=0x50005". True when any CURRENTLY-active bit is
set: bit0 under-voltage now, bit1 ARM freq capped now, bit2 currently throttled, bit3 soft
temp limit now. The 0x1_0000+ bits are "happened since boot" latches, deliberately ignored
so a past blip does not show as a permanent warning. None if unparseable."""
try:
val = int(out.strip().split("=", 1)[1], 16)
return bool(val & 0xF)
except (IndexError, ValueError):
return None
def read_health(run=None, which=None) -> dict:
"""{'temp': float|None, 'throttle': bool|None}. `run`/`which` are injectable for tests;
default to subprocess/shutil. Returns Nones immediately when vcgencmd is absent (off-Pi)."""
which = which or shutil.which
run = run or _run
if which("vcgencmd") is None:
return {"temp": None, "throttle": None}
return {"temp": parse_temp(run(["vcgencmd", "measure_temp"])),
"throttle": parse_throttle(run(["vcgencmd", "get_throttled"]))}
def _run(cmd: list) -> str: # pragma: no cover (thin subprocess wrapper, exercised on device)
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=2).stdout
except Exception:
return ""