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).
This commit is contained in:
parent
aa4378e906
commit
752afc3f00
13 changed files with 391 additions and 32 deletions
|
|
@ -74,6 +74,13 @@ class PostClient:
|
||||||
# 4xx codes that explicitly mean try-again (408 Request Timeout, 425 Too Early, 429 Too
|
# 4xx codes that explicitly mean try-again (408 Request Timeout, 425 Too Early, 429 Too
|
||||||
# Many Requests). Counting these toward the poison drop cap would delete real events during
|
# Many Requests). Counting these toward the poison drop cap would delete real events during
|
||||||
# a proxy/server hiccup. Only a definite "bad request" 4xx is a permanent rejection.
|
# a proxy/server hiccup. Only a definite "bad request" 4xx is a permanent rejection.
|
||||||
if code in (408, 425, 429) or code >= 500:
|
#
|
||||||
|
# 401 is transient too: the server returns it for a STALE timestamp (abs(now-ts) > window),
|
||||||
|
# which is exactly what an RTC-less Pi hits after a power cut until NTP steps the clock —
|
||||||
|
# the event is valid and will be accepted verbatim once time syncs. The only permanent 401
|
||||||
|
# cause is a wrong/rotated secret, a provisioning error to fix; retrying (events stay queued,
|
||||||
|
# bounded by max_rows) is far safer than poison-dropping a real fall alert. Poison-drop is
|
||||||
|
# reserved for a definite CONTENT rejection (400/422), never an auth/clock condition.
|
||||||
|
if code in (401, 408, 425, 429) or code >= 500:
|
||||||
return SendResult.TRANSIENT
|
return SendResult.TRANSIENT
|
||||||
return SendResult.REJECTED
|
return SendResult.REJECTED
|
||||||
|
|
|
||||||
13
edge/app.py
13
edge/app.py
|
|
@ -29,10 +29,14 @@ def _head_boxes(persons: list) -> list:
|
||||||
return boxes
|
return boxes
|
||||||
|
|
||||||
|
|
||||||
def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn):
|
def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None):
|
||||||
ok, frame = source.read()
|
ok, frame = source.read()
|
||||||
if not ok:
|
if not ok:
|
||||||
return []
|
# No frame this tick. Return None (not []) so the daemon can tell "camera produced no
|
||||||
|
# frame" apart from "frame processed, no events" — a dead/unplugged USB camera returns
|
||||||
|
# (False, None) forever, and counting it as a healthy frame made a monitoring outage
|
||||||
|
# look perfectly green (fps~=target, cameras_erroring=0).
|
||||||
|
return None
|
||||||
now = now_fn()
|
now = now_fn()
|
||||||
persons = pose.infer(frame)
|
persons = pose.infer(frame)
|
||||||
tracks = tracker.update(persons, now) # RAW keypoints; smoothing happens per-track below
|
tracks = tracker.update(persons, now) # RAW keypoints; smoothing happens per-track below
|
||||||
|
|
@ -42,7 +46,10 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
|
||||||
f.keypoints = pool.smoother_for(tr.track_id).smooth(f.keypoints, now)
|
f.keypoints = pool.smoother_for(tr.track_id).smooth(f.keypoints, now)
|
||||||
ring.push(now, frame)
|
ring.push(now, frame)
|
||||||
h, w = frame.shape[:2]
|
h, w = frame.shape[:2]
|
||||||
events = engine.process(tracks, lost, now, frame_wh=(w, h))
|
# `now` (monotonic) drives detector timers; wall time stamps the event (occurred_at). Off the
|
||||||
|
# daemon (direct callers / tests) wall_fn is None → engine falls back to now, as before.
|
||||||
|
wall_now = wall_fn() if wall_fn is not None else None
|
||||||
|
events = engine.process(tracks, lost, now, frame_wh=(w, h), wall_now=wall_now)
|
||||||
for tr in lost:
|
for tr in lost:
|
||||||
pool.forget(tr.track_id)
|
pool.forget(tr.track_id)
|
||||||
if events:
|
if events:
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ def _default_log(msg, exc=None): # pragma: no cover (thin stderr wrapper)
|
||||||
|
|
||||||
def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
|
def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
|
||||||
sync_every=150, heartbeat_every=900, flush_every=300,
|
sync_every=150, heartbeat_every=900, flush_every=300,
|
||||||
frame_interval=0.0, zones_for=None, log=None):
|
frame_interval=0.0, zones_for=None, log=None,
|
||||||
|
heartbeat_interval_s=None, health_fn=None):
|
||||||
# `runtime` may be a single PipelineRuntime or a list of them (multi-camera). All
|
# `runtime` may be a single PipelineRuntime or a list of them (multi-camera). All
|
||||||
# cameras share this loop, one shared clock read per tick, and the periodic
|
# cameras share this loop, one shared clock read per tick, and the periodic
|
||||||
# sync/heartbeat/flush. A per-camera step exception is counted and rate-limit-logged,
|
# sync/heartbeat/flush. A per-camera step exception is counted and rate-limit-logged,
|
||||||
|
|
@ -29,39 +30,72 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
|
||||||
zones_for = lambda cfg, rt: zones_from_config(cfg)
|
zones_for = lambda cfg, rt: zones_from_config(cfg)
|
||||||
if log is None:
|
if log is None:
|
||||||
log = _default_log
|
log = _default_log
|
||||||
|
if health_fn is None:
|
||||||
|
from edge.health import read_health
|
||||||
|
health_fn = read_health
|
||||||
counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0}
|
counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0}
|
||||||
streak = {} # id(rt)/"periodic" -> consecutive-error count, for rate-limited logging
|
streak = {} # id(rt)/"periodic" -> consecutive-error count, for rate-limited logging
|
||||||
|
|
||||||
def note(key, label, exc):
|
def note(key, label, exc=None):
|
||||||
counters["errors"] += 1
|
counters["errors"] += 1
|
||||||
n = streak.get(key, 0) + 1
|
n = streak.get(key, 0) + 1
|
||||||
streak[key] = n
|
streak[key] = n
|
||||||
if n == 1 or n % _LOG_EVERY == 0:
|
if n == 1 or n % _LOG_EVERY == 0:
|
||||||
|
if exc is not None:
|
||||||
log(f"{label}: {type(exc).__name__}: {exc} (consecutive #{n})", exc)
|
log(f"{label}: {type(exc).__name__}: {exc} (consecutive #{n})", exc)
|
||||||
|
else:
|
||||||
|
log(f"{label} (consecutive #{n})", None)
|
||||||
|
|
||||||
beat_anchor = None # (wall_time, frames) at the previous heartbeat, for measured fps
|
beat_anchor = None # (wall_time, frames) at the previous heartbeat, for measured fps
|
||||||
|
last_beat = None # wall time of the last heartbeat, for the time-based cadence
|
||||||
|
reported_version = None # last config version the server told us; echoed in heartbeats
|
||||||
i = 0
|
i = 0
|
||||||
while iterations is None or i < iterations:
|
while iterations is None or i < iterations:
|
||||||
i += 1
|
i += 1
|
||||||
now = clock() # one timestamp per tick, shared across cameras
|
now = clock() # one timestamp per tick, shared across cameras
|
||||||
if beat_anchor is None:
|
if beat_anchor is None:
|
||||||
beat_anchor = (now, 0)
|
beat_anchor = (now, 0)
|
||||||
|
if last_beat is None:
|
||||||
|
last_beat = now
|
||||||
for rt in runtimes:
|
for rt in runtimes:
|
||||||
try:
|
try:
|
||||||
rt.step(now)
|
produced = rt.step(now)
|
||||||
counters["frames"] += 1
|
|
||||||
streak.pop(id(rt), None) # recovered — reset the streak
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
cam = getattr(rt, "camera_id", "?")
|
cam = getattr(rt, "camera_id", "?")
|
||||||
note(id(rt), f"camera {cam} step failed", e)
|
note(id(rt), f"camera {cam} step failed", e)
|
||||||
|
continue
|
||||||
|
if produced is None:
|
||||||
|
# step() returned no frame (unplugged/browned-out camera). Count it toward the
|
||||||
|
# erroring streak instead of as a healthy frame — otherwise a dead camera ticks
|
||||||
|
# frames++ forever and reports fps~=target with cameras_erroring=0 (a silent
|
||||||
|
# monitoring outage that looks green). A single dropped frame self-clears on the
|
||||||
|
# next good frame, so only SUSTAINED starvation shows as erroring.
|
||||||
|
cam = getattr(rt, "camera_id", "?")
|
||||||
|
note(id(rt), f"camera {cam} produced no frame")
|
||||||
|
else:
|
||||||
|
counters["frames"] += 1
|
||||||
|
streak.pop(id(rt), None) # recovered — reset the streak
|
||||||
try:
|
try:
|
||||||
if sync_every and i % sync_every == 0:
|
if sync_every and i % sync_every == 0:
|
||||||
cfg = client.fetch_config()
|
cfg = client.fetch_config()
|
||||||
if cfg is not None:
|
if cfg is not None:
|
||||||
for rt in runtimes:
|
for rt in runtimes:
|
||||||
rt.apply_zones(zones_for(cfg, rt))
|
rt.apply_zones(zones_for(cfg, rt))
|
||||||
|
# Remember the config version so heartbeats can echo it back — the server's
|
||||||
|
# config-drift indicator is meaningless until the device reports what it runs.
|
||||||
|
if isinstance(cfg, dict) and cfg.get("version") is not None:
|
||||||
|
reported_version = int(cfg["version"])
|
||||||
counters["syncs"] += 1
|
counters["syncs"] += 1
|
||||||
if heartbeat_every and i % heartbeat_every == 0:
|
# Heartbeat cadence: time-based when heartbeat_interval_s is set (production), so the
|
||||||
|
# interval is predictable regardless of per-tick cost (CPU MoveNet, multi-cam, thermal
|
||||||
|
# throttle can push a tick past 100ms) — an iteration count cannot guarantee a wall-clock
|
||||||
|
# cadence, and the server marks a device offline on ELAPSED time. Falls back to the
|
||||||
|
# iteration count when unset (used by the deterministic unit tests).
|
||||||
|
if heartbeat_interval_s:
|
||||||
|
do_beat = (now - last_beat) >= heartbeat_interval_s
|
||||||
|
else:
|
||||||
|
do_beat = bool(heartbeat_every) and (i % heartbeat_every == 0)
|
||||||
|
if do_beat:
|
||||||
# Measured fps over the window since the last beat, and per-camera health, so
|
# Measured fps over the window since the last beat, and per-camera health, so
|
||||||
# the server can tell a live device from a degraded/dead one (was hardcoded 0.0
|
# the server can tell a live device from a degraded/dead one (was hardcoded 0.0
|
||||||
# + a frames counter that ticked even when no frame was read).
|
# + a frames counter that ticked even when no frame was read).
|
||||||
|
|
@ -71,10 +105,19 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
|
||||||
erroring = sum(1 for rt in runtimes if id(rt) in streak)
|
erroring = sum(1 for rt in runtimes if id(rt) in streak)
|
||||||
metrics = {"fps": round(fps, 2), "frames": counters["frames"],
|
metrics = {"fps": round(fps, 2), "frames": counters["frames"],
|
||||||
"cameras_total": len(runtimes), "cameras_erroring": erroring,
|
"cameras_total": len(runtimes), "cameras_erroring": erroring,
|
||||||
"errors": counters["errors"]}
|
"errors": counters["errors"], "queue_pending": queue.pending()}
|
||||||
|
# Pi thermal telemetry (None off-Pi or on failure → field omitted, never a fake 0).
|
||||||
|
health = health_fn()
|
||||||
|
if health.get("temp") is not None:
|
||||||
|
metrics["temp"] = health["temp"]
|
||||||
|
if health.get("throttle") is not None:
|
||||||
|
metrics["throttle"] = health["throttle"]
|
||||||
|
if reported_version is not None:
|
||||||
|
metrics["config_version"] = reported_version
|
||||||
if client.send_heartbeat(metrics):
|
if client.send_heartbeat(metrics):
|
||||||
counters["beats"] += 1
|
counters["beats"] += 1
|
||||||
beat_anchor = (now, counters["frames"])
|
beat_anchor = (now, counters["frames"])
|
||||||
|
last_beat = now
|
||||||
if flush_every and i % flush_every == 0:
|
if flush_every and i % flush_every == 0:
|
||||||
queue.flush()
|
queue.flush()
|
||||||
counters["flushes"] += 1
|
counters["flushes"] += 1
|
||||||
|
|
@ -159,10 +202,16 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
|
||||||
cfg = load_config(dict(os.environ))
|
cfg = load_config(dict(os.environ))
|
||||||
require_upload_config(cfg) # fail loud now, not silently upload nothing forever
|
require_upload_config(cfg) # fail loud now, not silently upload nothing forever
|
||||||
nonce = lambda: os.urandom(8).hex()
|
nonce = lambda: os.urandom(8).hex()
|
||||||
now = time.time
|
# Two clocks, deliberately: the loop/detector timers run on a MONOTONIC clock so an NTP step
|
||||||
|
# (a Pi 5 has no RTC battery; it boots on stale fake-hwclock time until chrony corrects it)
|
||||||
|
# cannot instantly satisfy immobile_s, expire every track, or reset an immobility countdown.
|
||||||
|
# Wall time is used only where a real timestamp is required: the HMAC X-Timestamp headers,
|
||||||
|
# which the server validates against its own wall clock, and Event.occurred_at.
|
||||||
|
mono = time.monotonic
|
||||||
|
wall = time.time
|
||||||
base = cfg.cafe24_url.rstrip("/")
|
base = cfg.cafe24_url.rstrip("/")
|
||||||
client = ServerClient(base, cfg.device_id, cfg.secret, nonce, now)
|
client = ServerClient(base, cfg.device_id, cfg.secret, nonce, wall)
|
||||||
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now)
|
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, wall)
|
||||||
queue = OfflineQueue(queue_db_path(dict(os.environ)), post)
|
queue = OfflineQueue(queue_db_path(dict(os.environ)), post)
|
||||||
|
|
||||||
pose = build_pose() # shared across cameras
|
pose = build_pose() # shared across cameras
|
||||||
|
|
@ -170,12 +219,13 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
|
||||||
def make_runtime(cam, source):
|
def make_runtime(cam, source):
|
||||||
# Per-camera config so events are stamped with that camera's id.
|
# Per-camera config so events are stamped with that camera's id.
|
||||||
cam_cfg = replace(cfg, camera_id=cam, engine=replace(cfg.engine, camera_id=cam))
|
cam_cfg = replace(cfg, camera_id=cam, engine=replace(cfg.engine, camera_id=cam))
|
||||||
return PipelineRuntime(cam_cfg, source, pose, queue)
|
return PipelineRuntime(cam_cfg, source, pose, queue, wall_clock=wall)
|
||||||
|
|
||||||
# A missing/unseated camera is skipped (logged), not fatal — the others still run.
|
# A missing/unseated camera is skipped (logged), not fatal — the others still run.
|
||||||
runtimes = build_runtimes(parse_cameras(dict(os.environ), cfg.camera_id),
|
runtimes = build_runtimes(parse_cameras(dict(os.environ), cfg.camera_id),
|
||||||
build_source, make_runtime)
|
build_source, make_runtime)
|
||||||
|
|
||||||
run_loop(runtimes, client, queue, clock=now, sleep=time.sleep, iterations=None,
|
run_loop(runtimes, client, queue, clock=mono, sleep=time.sleep, iterations=None,
|
||||||
frame_interval=frame_interval_from_env(dict(os.environ)),
|
frame_interval=frame_interval_from_env(dict(os.environ)),
|
||||||
|
heartbeat_interval_s=30, # predictable cadence; server marks offline after 120s (4x)
|
||||||
zones_for=lambda c, rt: zones_from_config(c, rt.camera_id))
|
zones_for=lambda c, rt: zones_from_config(c, rt.camera_id))
|
||||||
|
|
|
||||||
|
|
@ -40,15 +40,21 @@ class EventEngine:
|
||||||
self._last_emit = {k: v for k, v in self._last_emit.items()
|
self._last_emit = {k: v for k, v in self._last_emit.items()
|
||||||
if not (len(k) >= 2 and k[1] == track_id)}
|
if not (len(k) >= 2 and k[1] == track_id)}
|
||||||
|
|
||||||
def _mk(self, etype: EventType, now: float, track_id=None, zone_id=None,
|
def _mk(self, etype: EventType, occurred_at: float, track_id=None, zone_id=None,
|
||||||
metrics=None, spatial=None) -> Event:
|
metrics=None, spatial=None) -> Event:
|
||||||
m = metrics or {}
|
m = metrics or {}
|
||||||
duration_s = m.get("immobility_s") or m.get("waited_s") or 0.0
|
duration_s = m.get("immobility_s") or m.get("waited_s") or 0.0
|
||||||
return Event(etype, severity_for(etype), self.cfg.camera_id, track_id,
|
return Event(etype, severity_for(etype), self.cfg.camera_id, track_id,
|
||||||
zone_id, now, duration_s, m, spatial)
|
zone_id, occurred_at, duration_s, m, spatial)
|
||||||
|
|
||||||
def process(self, active_tracks: list[Track], lost_tracks: list[Track],
|
def process(self, active_tracks: list[Track], lost_tracks: list[Track],
|
||||||
now: float, frame_wh=None) -> list[Event]:
|
now: float, frame_wh=None, wall_now: float | None = None) -> list[Event]:
|
||||||
|
# `now` is a MONOTONIC clock (never jumps): it drives every detector timer, hysteresis,
|
||||||
|
# and cooldown, so an NTP step cannot instantly satisfy immobile_s / expire all tracks /
|
||||||
|
# reset a genuine immobility countdown. `wall_now` is real wall-clock time, used ONLY to
|
||||||
|
# stamp Event.occurred_at (the server correlates and retains by wall time). Defaults to
|
||||||
|
# `now` so single-clock callers (unit tests) keep their existing occurred_at behavior.
|
||||||
|
occurred_at = wall_now if wall_now is not None else now
|
||||||
events: list[Event] = []
|
events: list[Event] = []
|
||||||
self.ghost.on_lost(lost_tracks, now)
|
self.ghost.on_lost(lost_tracks, now)
|
||||||
|
|
||||||
|
|
@ -62,13 +68,13 @@ class EventEngine:
|
||||||
state = self.fall._states.get(tr.track_id)
|
state = self.fall._states.get(tr.track_id)
|
||||||
self.ghost.note_state(tr.track_id, state.name if state else "STANDING")
|
self.ghost.note_state(tr.track_id, state.name if state else "STANDING")
|
||||||
if sig == "fall" and self._allow((EventType.FALL, tr.track_id), now):
|
if sig == "fall" and self._allow((EventType.FALL, tr.track_id), now):
|
||||||
events.append(self._mk(EventType.FALL, now, tr.track_id, metrics=meta))
|
events.append(self._mk(EventType.FALL, occurred_at, tr.track_id, metrics=meta))
|
||||||
elif sig == "no_motion" and self._allow((EventType.NO_MOTION, tr.track_id), now):
|
elif sig == "no_motion" and self._allow((EventType.NO_MOTION, tr.track_id), now):
|
||||||
events.append(self._mk(EventType.NO_MOTION, now, tr.track_id, metrics=meta))
|
events.append(self._mk(EventType.NO_MOTION, occurred_at, tr.track_id, metrics=meta))
|
||||||
|
|
||||||
fired, imeta = self.immobility.update(tr, now, in_danger_zone=in_zone)
|
fired, imeta = self.immobility.update(tr, now, in_danger_zone=in_zone)
|
||||||
if fired and self._allow((EventType.IMMOBILITY, tr.track_id), now):
|
if fired and self._allow((EventType.IMMOBILITY, tr.track_id), now):
|
||||||
events.append(self._mk(EventType.IMMOBILITY, now, tr.track_id, metrics=imeta))
|
events.append(self._mk(EventType.IMMOBILITY, occurred_at, tr.track_id, metrics=imeta))
|
||||||
|
|
||||||
for hit in zone_hits:
|
for hit in zone_hits:
|
||||||
# 협착(pinch) is the highest-danger zone (machinery entrapment) and must emit
|
# 협착(pinch) is the highest-danger zone (machinery entrapment) and must emit
|
||||||
|
|
@ -78,12 +84,12 @@ class EventEngine:
|
||||||
"restricted": EventType.ZONE_RESTRICTED}.get(
|
"restricted": EventType.ZONE_RESTRICTED}.get(
|
||||||
hit["level"], EventType.ZONE_WARNING)
|
hit["level"], EventType.ZONE_WARNING)
|
||||||
if self._allow((etype, tr.track_id, hit["zone_id"]), now):
|
if self._allow((etype, tr.track_id, hit["zone_id"]), now):
|
||||||
events.append(self._mk(etype, now, tr.track_id, zone_id=hit["zone_id"]))
|
events.append(self._mk(etype, occurred_at, tr.track_id, zone_id=hit["zone_id"]))
|
||||||
|
|
||||||
for g in self.ghost.update(active_tracks, now):
|
for g in self.ghost.update(active_tracks, now):
|
||||||
key = (EventType.GHOST, g["track_id"])
|
key = (EventType.GHOST, g["track_id"])
|
||||||
if self._allow(key, now):
|
if self._allow(key, now):
|
||||||
events.append(self._mk(EventType.GHOST, now, track_id=g["track_id"],
|
events.append(self._mk(EventType.GHOST, occurred_at, track_id=g["track_id"],
|
||||||
spatial=g["spatial"],
|
spatial=g["spatial"],
|
||||||
metrics={"last_state": g["last_state"],
|
metrics={"last_state": g["last_state"],
|
||||||
"waited_s": g["waited_s"]}))
|
"waited_s": g["waited_s"]}))
|
||||||
|
|
|
||||||
47
edge/health.py
Normal file
47
edge/health.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""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 ""
|
||||||
|
|
@ -64,9 +64,19 @@ class OfflineQueue:
|
||||||
"SELECT id, payload, snaps, attempts FROM queue ORDER BY id").fetchall()
|
"SELECT id, payload, snaps, attempts FROM queue ORDER BY id").fetchall()
|
||||||
sent = 0
|
sent = 0
|
||||||
for rid, payload_json, snaps_json, attempts in rows:
|
for rid, payload_json, snaps_json, attempts in rows:
|
||||||
|
try:
|
||||||
payload = json.loads(payload_json)
|
payload = json.loads(payload_json)
|
||||||
snaps = [base64.b64decode(s) for s in json.loads(snaps_json)]
|
snaps = [base64.b64decode(s) for s in json.loads(snaps_json)]
|
||||||
event, snapshots = self._rehydrate(payload, snaps)
|
event, snapshots = self._rehydrate(payload, snaps)
|
||||||
|
except Exception:
|
||||||
|
# An undecodable row — a torn SD-card write, or schema drift from an older edge
|
||||||
|
# build whose payload lacks a key _rehydrate indexes — can NEVER be delivered.
|
||||||
|
# flush() breaks on the first undelivered row to preserve order, so leaving this
|
||||||
|
# row in place would wedge every queued safety event BEHIND it forever. Drop the
|
||||||
|
# single unrecoverable row and continue; losing it beats freezing the whole queue.
|
||||||
|
self.db.execute("DELETE FROM queue WHERE id = ?", (rid,))
|
||||||
|
self.db.commit()
|
||||||
|
continue
|
||||||
# A row queued before event_uuid existed has none stored; _rehydrate minted a fresh
|
# A row queued before event_uuid existed has none stored; _rehydrate minted a fresh
|
||||||
# one just now. Persist it so every retry of this row reuses the SAME key — otherwise
|
# one just now. Persist it so every retry of this row reuses the SAME key — otherwise
|
||||||
# each flush sends a different uuid and a timeout-retried delivery duplicates on the
|
# each flush sends a different uuid and a timeout-retried delivery duplicates on the
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,15 @@ from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
|
||||||
|
|
||||||
|
|
||||||
class PipelineRuntime:
|
class PipelineRuntime:
|
||||||
def __init__(self, config, source, pose, sink):
|
def __init__(self, config, source, pose, sink, wall_clock=None):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.camera_id = config.camera_id # for per-camera zone routing (multi-cam)
|
self.camera_id = config.camera_id # for per-camera zone routing (multi-cam)
|
||||||
self.source = source
|
self.source = source
|
||||||
self.pose = pose
|
self.pose = pose
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
|
# Wall clock for Event.occurred_at only; the loop clock (step's `now`) is monotonic. None
|
||||||
|
# off-daemon -> run_once stamps occurred_at with the monotonic `now`, preserving old tests.
|
||||||
|
self._wall = wall_clock
|
||||||
self.tracker = IouTracker()
|
self.tracker = IouTracker()
|
||||||
self.pool = TrackSmootherPool()
|
self.pool = TrackSmootherPool()
|
||||||
self.zone_detector = ZoneIntrusionDetector([], config.zone)
|
self.zone_detector = ZoneIntrusionDetector([], config.zone)
|
||||||
|
|
@ -32,7 +35,7 @@ class PipelineRuntime:
|
||||||
def step(self, now: float) -> list:
|
def step(self, now: float) -> list:
|
||||||
return run_once(self.source, self.pose, self.tracker, self.pool,
|
return run_once(self.source, self.pose, self.tracker, self.pool,
|
||||||
self.engine, self.ring, self.snapshotter, self.sink,
|
self.engine, self.ring, self.snapshotter, self.sink,
|
||||||
now_fn=lambda: now)
|
now_fn=lambda: now, wall_fn=self._wall)
|
||||||
|
|
||||||
def apply_zones(self, zones: list) -> None:
|
def apply_zones(self, zones: list) -> None:
|
||||||
self.zone_detector.zones = list(zones)
|
self.zone_detector.zones = list(zones)
|
||||||
|
|
|
||||||
|
|
@ -49,11 +49,21 @@ def test_send_classifies_5xx_as_transient():
|
||||||
client = PostClient("https://x", "pi-01", "s", lambda: "n", lambda: 1.0,
|
client = PostClient("https://x", "pi-01", "s", lambda: "n", lambda: 1.0,
|
||||||
http_post=lambda *a, **k: _FakeResp(500))
|
http_post=lambda *a, **k: _FakeResp(500))
|
||||||
assert client.send(_event(), []) is SendResult.TRANSIENT
|
assert client.send(_event(), []) is SendResult.TRANSIENT
|
||||||
for code in (408, 425, 429): # try-again 4xx codes are transient, not permanent rejections
|
for code in (401, 408, 425, 429): # try-again codes + 401 (stale clock) are transient
|
||||||
c = PostClient("https://x", "pi-01", "s", lambda: "n", lambda: 1.0,
|
c = PostClient("https://x", "pi-01", "s", lambda: "n", lambda: 1.0,
|
||||||
http_post=lambda *a, _c=code, **k: _FakeResp(_c))
|
http_post=lambda *a, _c=code, **k: _FakeResp(_c))
|
||||||
assert c.send(_event(), []) is SendResult.TRANSIENT
|
assert c.send(_event(), []) is SendResult.TRANSIENT
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_treats_401_stale_timestamp_as_transient_not_poison():
|
||||||
|
# The server returns 401 for a STALE timestamp (RTC-less Pi after a power cut, before NTP).
|
||||||
|
# That is transient — the event is valid and accepted once the clock syncs — so it must NOT
|
||||||
|
# count toward the poison drop cap, or a real fall alert captured during clock skew is deleted
|
||||||
|
# after ~20 retries instead of delivered.
|
||||||
|
client = PostClient("https://x", "pi-01", "s", lambda: "n", lambda: 1.0,
|
||||||
|
http_post=lambda *a, **k: _FakeResp(401))
|
||||||
|
assert client.send(_event(), []) is SendResult.TRANSIENT
|
||||||
|
|
||||||
def test_send_classifies_4xx_as_rejected():
|
def test_send_classifies_4xx_as_rejected():
|
||||||
# A definite 4xx (not 429) means the server will never accept this event — the queue counts it
|
# A definite 4xx (not 429) means the server will never accept this event — the queue counts it
|
||||||
# toward the poison drop cap so it can eventually be discarded and unblock the queue.
|
# toward the poison drop cap so it can eventually be discarded and unblock the queue.
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,30 @@ def test_run_once_pipeline_emits_fall_after_prone():
|
||||||
assert any(e.event_type == EventType.FALL for e in sink.events)
|
assert any(e.event_type == EventType.FALL for e in sink.events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_once_stamps_events_with_wall_clock_not_monotonic():
|
||||||
|
# run_once must thread a separate wall clock through to occurred_at: the daemon runs the loop on
|
||||||
|
# a monotonic clock (NTP-step-immune timers) but events have to carry real wall time.
|
||||||
|
src, pose = _Source(), _Pose()
|
||||||
|
tracker = IouTracker()
|
||||||
|
pool = TrackSmootherPool()
|
||||||
|
engine = EventEngine(
|
||||||
|
FallDetector(FallConfig(prone_confirm_s=0.5)),
|
||||||
|
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
|
||||||
|
GhostIncidentDetector(GhostConfig()),
|
||||||
|
ZoneIntrusionDetector([], ZoneConfig()),
|
||||||
|
EngineConfig(camera_id="cam-01"),
|
||||||
|
)
|
||||||
|
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _Sink()
|
||||||
|
mono = iter([0.0, 0.6]) # monotonic loop clock (drives prone_confirm_s)
|
||||||
|
wallt = iter([1_700_000_000.0, 1_700_000_000.6]) # wall clock (stamps occurred_at)
|
||||||
|
run_once(src, pose, tracker, pool, engine, ring, snap, sink,
|
||||||
|
now_fn=lambda: next(mono), wall_fn=lambda: next(wallt))
|
||||||
|
run_once(src, pose, tracker, pool, engine, ring, snap, sink,
|
||||||
|
now_fn=lambda: next(mono), wall_fn=lambda: next(wallt))
|
||||||
|
falls = [e for e in sink.events if e.event_type == EventType.FALL]
|
||||||
|
assert falls and falls[0].occurred_at == 1_700_000_000.6
|
||||||
|
|
||||||
|
|
||||||
def _person_at(cx):
|
def _person_at(cx):
|
||||||
# standing person centered at cx; nose at (cx, 60)
|
# standing person centered at cx; nose at (cx, 60)
|
||||||
kps = [Keypoint(cx, 150, 0.9) for _ in range(17)]
|
kps = [Keypoint(cx, 150, 0.9) for _ in range(17)]
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,9 @@ class _Client:
|
||||||
|
|
||||||
|
|
||||||
class _Queue:
|
class _Queue:
|
||||||
def __init__(self): self.flushes = 0
|
def __init__(self, pending=0): self.flushes = 0; self._pending = pending
|
||||||
def flush(self): self.flushes += 1; return 0
|
def flush(self): self.flushes += 1; return 0
|
||||||
|
def pending(self): return self._pending
|
||||||
|
|
||||||
|
|
||||||
def test_loop_steps_and_periodics():
|
def test_loop_steps_and_periodics():
|
||||||
|
|
@ -98,6 +99,66 @@ def test_heartbeat_flags_a_failing_camera():
|
||||||
assert m["cameras_erroring"] == 1 # the server can now see the camera is down
|
assert m["cameras_erroring"] == 1 # the server can now see the camera is down
|
||||||
|
|
||||||
|
|
||||||
|
class _NoFrameRuntime:
|
||||||
|
# step() returns None on a tick with no frame (dead/unplugged camera), a list otherwise.
|
||||||
|
camera_id = "cam-dead"
|
||||||
|
def __init__(self): self.steps = 0
|
||||||
|
def step(self, now): self.steps += 1; return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_frame_counts_as_erroring_not_a_healthy_frame():
|
||||||
|
# A camera that returns no frame every tick (unplugged USB) must NOT tick frames++ and must
|
||||||
|
# surface as erroring — otherwise a monitoring outage looks perfectly green (the whole point
|
||||||
|
# of run_once returning None instead of []).
|
||||||
|
rt, cl = _NoFrameRuntime(), _Client()
|
||||||
|
ticks = iter(float(t) for t in range(1000))
|
||||||
|
counters = run_loop(rt, cl, _Queue(), clock=lambda: next(ticks), sleep=lambda s: None,
|
||||||
|
iterations=10, sync_every=99, heartbeat_every=10, flush_every=99,
|
||||||
|
log=lambda *a, **k: None)
|
||||||
|
assert rt.steps == 10
|
||||||
|
assert counters["frames"] == 0 # no frame was ever counted as healthy
|
||||||
|
m = cl.metrics[0]
|
||||||
|
assert m["fps"] == 0.0
|
||||||
|
assert m["cameras_erroring"] == 1 # the dead camera is visible to the server
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_reports_queue_depth_and_echoes_config_version():
|
||||||
|
class _VersionClient(_Client):
|
||||||
|
def fetch_config(self):
|
||||||
|
self.configs += 1
|
||||||
|
return {"version": 7, "zones": [], "thresholds": {}}
|
||||||
|
rt, cl, q = _Runtime(), _VersionClient(), _Queue(pending=4)
|
||||||
|
ticks = iter(float(t) for t in range(1000))
|
||||||
|
# sync at frame 5 (captures version 7) BEFORE the heartbeat at frame 10.
|
||||||
|
run_loop(rt, cl, q, clock=lambda: next(ticks), sleep=lambda s: None,
|
||||||
|
iterations=10, sync_every=5, heartbeat_every=10, flush_every=99)
|
||||||
|
m = cl.metrics[0]
|
||||||
|
assert m["queue_pending"] == 4 # transmitted so the dashboard can show backlog
|
||||||
|
assert m["config_version"] == 7 # echoed so config-drift detection actually works
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_omits_config_version_until_a_config_is_fetched():
|
||||||
|
# Before any sync, the device does not know its version — it must OMIT config_version rather
|
||||||
|
# than send a fake 0 (which made the server's drift indicator fire on every device forever).
|
||||||
|
rt, cl, q = _Runtime(), _Client(), _Queue()
|
||||||
|
ticks = iter(float(t) for t in range(1000))
|
||||||
|
run_loop(rt, cl, q, clock=lambda: next(ticks), sleep=lambda s: None,
|
||||||
|
iterations=10, sync_every=99, heartbeat_every=10, flush_every=99)
|
||||||
|
assert "config_version" not in cl.metrics[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_based_heartbeat_fires_on_elapsed_wall_time_not_iteration_count():
|
||||||
|
# Production uses heartbeat_interval_s so the cadence is predictable no matter how slow a tick
|
||||||
|
# is. With the clock advancing 10s/tick and a 30s interval, a beat fires every ~3 ticks.
|
||||||
|
rt, cl, q = _Runtime(), _Client(), _Queue()
|
||||||
|
ticks = iter(float(t * 10) for t in range(1000)) # t = 0,10,20,30,...
|
||||||
|
run_loop(rt, cl, q, clock=lambda: next(ticks), sleep=lambda s: None,
|
||||||
|
iterations=10, sync_every=99, heartbeat_every=None, flush_every=99,
|
||||||
|
heartbeat_interval_s=30)
|
||||||
|
# ticks at 0,10,20,30,...,90. Beats when now-last_beat >= 30: at 30, 60, 90 -> 3 beats.
|
||||||
|
assert cl.beats == 3
|
||||||
|
|
||||||
|
|
||||||
def test_loop_survives_flush_exception():
|
def test_loop_survives_flush_exception():
|
||||||
class BoomQueue(_Queue):
|
class BoomQueue(_Queue):
|
||||||
def flush(self):
|
def flush(self):
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,17 @@ def _lying_track(track, t):
|
||||||
track.frames.append(TrackFrame(t, BBox(90, 290, 210, 320), _lying_kps()))
|
track.frames.append(TrackFrame(t, BBox(90, 290, 210, 320), _lying_kps()))
|
||||||
return track
|
return track
|
||||||
|
|
||||||
|
def _standing_kps(cx=150):
|
||||||
|
kps = [Keypoint(cx, 150, 0.9) for _ in range(17)]
|
||||||
|
kps[5] = Keypoint(cx - 10, 100, 0.9); kps[6] = Keypoint(cx + 10, 100, 0.9) # shoulders above
|
||||||
|
kps[11] = Keypoint(cx - 10, 200, 0.9); kps[12] = Keypoint(cx + 10, 200, 0.9) # hips below
|
||||||
|
kps[0] = Keypoint(cx, 60, 0.9) # head above hips
|
||||||
|
return kps
|
||||||
|
|
||||||
|
def _standing_track(track, t, cx=150):
|
||||||
|
track.frames.append(TrackFrame(t, BBox(cx - 20, 60, cx + 20, 210), _standing_kps(cx)))
|
||||||
|
return track
|
||||||
|
|
||||||
def _engine():
|
def _engine():
|
||||||
return EventEngine(
|
return EventEngine(
|
||||||
FallDetector(FallConfig(prone_confirm_s=0.5)),
|
FallDetector(FallConfig(prone_confirm_s=0.5)),
|
||||||
|
|
@ -106,6 +117,64 @@ def test_pinch_zone_emits_pinch_event_not_warning():
|
||||||
assert not any(e.event_type == EventType.ZONE_WARNING for e in events)
|
assert not any(e.event_type == EventType.ZONE_WARNING for e in events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_simultaneous_tracks_have_independent_fall_state():
|
||||||
|
# Per-track detector state must not leak between two people monitored at once: a fall on track A
|
||||||
|
# must arm and emit for A only, never for a standing track B. All other engine/detector tests
|
||||||
|
# exercise a single person, so this pins the multi-person isolation the fall/immobility/zone
|
||||||
|
# timers rely on (they key every bit of state off track_id).
|
||||||
|
eng = _engine() # prone_confirm_s=0.5
|
||||||
|
a = Track(1, deque(maxlen=90)); b = Track(2, deque(maxlen=90))
|
||||||
|
_lying_track(a, 0.0); _standing_track(b, 0.0, cx=500)
|
||||||
|
eng.process([a, b], [], now=0.0)
|
||||||
|
_lying_track(a, 0.6); _standing_track(b, 0.6, cx=500)
|
||||||
|
events = eng.process([a, b], [], now=0.6)
|
||||||
|
falls = [e for e in events if e.event_type == EventType.FALL]
|
||||||
|
assert len(falls) == 1 # exactly one fall...
|
||||||
|
assert falls[0].track_id == 1 # ...for the lying track A, not the standing track B
|
||||||
|
|
||||||
|
|
||||||
|
def test_fall_fires_at_exactly_prone_confirm_s_inclusive_boundary():
|
||||||
|
# The confirm is `now - prone_since >= prone_confirm_s`, so a fall must fire at EXACTLY the
|
||||||
|
# threshold, not one frame later. Pins the inclusive boundary against a >= -> > regression that
|
||||||
|
# would silently delay every fall confirmation by a frame — up to +0.5s at a thermally throttled
|
||||||
|
# 2 fps, on the single most safety-critical event. Existing tests only feed comfortably-past
|
||||||
|
# values (0.6 vs 0.5), so none of them would catch that shift.
|
||||||
|
eng = _engine() # prone_confirm_s=0.5
|
||||||
|
tr = Track(1, deque(maxlen=90))
|
||||||
|
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0) # prone_since = 0.0
|
||||||
|
_lying_track(tr, 0.5) # exactly prone_confirm_s elapsed
|
||||||
|
events = eng.process([tr], [], now=0.5)
|
||||||
|
assert any(e.event_type == EventType.FALL for e in events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_occurred_at_uses_wall_clock_while_timers_use_monotonic():
|
||||||
|
# A fall is CONFIRMED by the monotonic timer (prone_confirm_s over the injected `now`), but the
|
||||||
|
# event must be STAMPED with wall time so an NTP step does not mis-timestamp evidence (and the
|
||||||
|
# server's wall-time retention purge does not delete it). Timer `now` and `wall_now` are
|
||||||
|
# independent axes here to prove occurred_at follows wall time, not the monotonic clock.
|
||||||
|
eng = _engine() # prone_confirm_s=0.5
|
||||||
|
tr = Track(1, deque(maxlen=90))
|
||||||
|
_lying_track(tr, 0.0)
|
||||||
|
eng.process([tr], [], now=0.0, wall_now=1_700_000_000.0)
|
||||||
|
_lying_track(tr, 0.6)
|
||||||
|
events = eng.process([tr], [], now=0.6, wall_now=1_700_000_000.6)
|
||||||
|
fall = [e for e in events if e.event_type == EventType.FALL]
|
||||||
|
assert len(fall) == 1
|
||||||
|
assert fall[0].occurred_at == 1_700_000_000.6 # wall time, NOT the monotonic 0.6
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_defaults_occurred_at_to_now_when_no_wall_clock():
|
||||||
|
# Single-clock callers (unit tests, direct callers) omit wall_now — occurred_at must fall back
|
||||||
|
# to the timer `now` so existing behavior is unchanged.
|
||||||
|
eng = _engine()
|
||||||
|
tr = Track(1, deque(maxlen=90))
|
||||||
|
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0)
|
||||||
|
_lying_track(tr, 0.6)
|
||||||
|
events = eng.process([tr], [], now=0.6)
|
||||||
|
fall = [e for e in events if e.event_type == EventType.FALL]
|
||||||
|
assert len(fall) == 1 and fall[0].occurred_at == 0.6
|
||||||
|
|
||||||
|
|
||||||
def test_allow_cooldown_primitive():
|
def test_allow_cooldown_primitive():
|
||||||
eng = _engine() # EngineConfig(camera_id="cam-01", cooldown_s=30.0)
|
eng = _engine() # EngineConfig(camera_id="cam-01", cooldown_s=30.0)
|
||||||
key = ("FALL", 1)
|
key = ("FALL", 1)
|
||||||
|
|
|
||||||
41
tests/test_health.py
Normal file
41
tests/test_health.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
from edge.health import parse_temp, parse_throttle, read_health
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_temp_reads_vcgencmd_output():
|
||||||
|
assert parse_temp("temp=54.3'C\n") == 54.3
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_temp_returns_none_on_garbage():
|
||||||
|
assert parse_temp("") is None
|
||||||
|
assert parse_temp("no equals sign") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_throttle_true_when_a_currently_active_bit_is_set():
|
||||||
|
assert parse_throttle("throttled=0x4") is True # bit2: currently throttled
|
||||||
|
assert parse_throttle("throttled=0x1") is True # bit0: under-voltage now
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_throttle_false_for_zero_and_for_past_only_latches():
|
||||||
|
assert parse_throttle("throttled=0x0") is False
|
||||||
|
# 0x50000 = bits 16,18 (under-voltage/throttle HAPPENED since boot) but nothing active now.
|
||||||
|
assert parse_throttle("throttled=0x50000") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_throttle_none_on_garbage():
|
||||||
|
assert parse_throttle("throttled=notahexnum") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_health_returns_nones_off_pi_without_touching_subprocess():
|
||||||
|
# No vcgencmd on the build machine / server: read_health must return Nones (never a fake 0)
|
||||||
|
# and must NOT invoke the runner at all.
|
||||||
|
calls = []
|
||||||
|
out = read_health(run=lambda cmd: calls.append(cmd) or "", which=lambda name: None)
|
||||||
|
assert out == {"temp": None, "throttle": None}
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_health_parses_injected_vcgencmd_on_pi():
|
||||||
|
def fake_run(cmd):
|
||||||
|
return "temp=61.7'C\n" if "measure_temp" in cmd else "throttled=0x0\n"
|
||||||
|
out = read_health(run=fake_run, which=lambda name: "/usr/bin/vcgencmd")
|
||||||
|
assert out == {"temp": 61.7, "throttle": False}
|
||||||
|
|
@ -120,6 +120,30 @@ def test_sustained_outage_never_drops_events(tmp_path):
|
||||||
assert q.pending() == 0 and _Outage.sent == [1] # drains once connectivity returns
|
assert q.pending() == 0 and _Outage.sent == [1] # drains once connectivity returns
|
||||||
|
|
||||||
|
|
||||||
|
def test_undecodable_row_is_dropped_and_unblocks_the_queue(tmp_path):
|
||||||
|
# A corrupt row (torn SD-card write / schema drift) can never be decoded, so it must not wedge
|
||||||
|
# every queued safety event behind it forever. flush() drops the single bad row and drains the
|
||||||
|
# rest — losing one unrecoverable event beats freezing the whole queue.
|
||||||
|
db = str(tmp_path / "q.db")
|
||||||
|
con = sqlite3.connect(db)
|
||||||
|
con.execute("CREATE TABLE queue (id INTEGER PRIMARY KEY AUTOINCREMENT, payload TEXT, "
|
||||||
|
"snaps TEXT, attempts INTEGER DEFAULT 0)")
|
||||||
|
con.execute("INSERT INTO queue (payload, snaps) VALUES (?, ?)",
|
||||||
|
("{not valid json", json.dumps([]))) # poison row at the head
|
||||||
|
good = {"event_type": "fall", "severity": "high", "camera_id": "cam-01", "track_id": 7,
|
||||||
|
"zone_id": None, "occurred_at": 100.0, "duration_s": 3.0, "metrics": {},
|
||||||
|
"spatial": None, "event_uuid": "u7"}
|
||||||
|
con.execute("INSERT INTO queue (payload, snaps) VALUES (?, ?)",
|
||||||
|
(json.dumps(good), json.dumps([]))) # good row behind it
|
||||||
|
con.commit(); con.close()
|
||||||
|
|
||||||
|
client = _Client()
|
||||||
|
q = OfflineQueue(db, client)
|
||||||
|
assert q.flush() == 1 # the good row drained past the dropped poison row
|
||||||
|
assert client.sent == [7]
|
||||||
|
assert q.pending() == 0 # both rows gone: poison dropped, good delivered
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_row_without_uuid_reuses_minted_uuid_across_retries(tmp_path):
|
def test_legacy_row_without_uuid_reuses_minted_uuid_across_retries(tmp_path):
|
||||||
# A row queued before event_uuid existed has none stored. On first flush a fresh uuid is
|
# A row queued before event_uuid existed has none stored. On first flush a fresh uuid is
|
||||||
# minted; it MUST be persisted so every retry reuses the SAME key — else each failed-then-
|
# minted; it MUST be persisted so every retry reuses the SAME key — else each failed-then-
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue