fix(edge): resilient multi-camera startup — skip a camera that fails to construct
build_runtimes() builds each camera's source+runtime in a try/except: a camera that raises at construction (e.g. an unseated/absent CSI camera whose Picamera2(...) throws) is logged and skipped so the working cameras still run, instead of an unguarded exception crash-looping the whole daemon (Restart=always). Raises only if no camera comes up. Closes the eager-construction gap the review flagged.
This commit is contained in:
parent
db376a756c
commit
c385be017c
2 changed files with 50 additions and 5 deletions
|
|
@ -65,6 +65,25 @@ def parse_cameras(env, default_camera_id):
|
|||
return [(backend, device, default_camera_id)]
|
||||
|
||||
|
||||
def build_runtimes(cameras, make_source, make_runtime, log=None):
|
||||
"""Build one (source -> runtime) per camera. A camera that fails to CONSTRUCT
|
||||
(e.g. an unseated/absent CSI camera whose Picamera2(...) raises) is logged and
|
||||
SKIPPED so it never crash-loops the whole daemon and the other cameras still run.
|
||||
Raises if no camera comes up (never run monitoring nothing)."""
|
||||
if log is None:
|
||||
import sys
|
||||
log = lambda m: print(m, file=sys.stderr)
|
||||
runtimes = []
|
||||
for backend, device, cam in cameras:
|
||||
try:
|
||||
runtimes.append(make_runtime(cam, make_source(backend, device)))
|
||||
except Exception as e:
|
||||
log(f"camera {cam} ({backend}:{device}) failed to start, skipping: {e}")
|
||||
if not runtimes:
|
||||
raise RuntimeError("no cameras could be started")
|
||||
return runtimes
|
||||
|
||||
|
||||
def main() -> None: # pragma: no cover (integration entrypoint, exercised on device)
|
||||
import time
|
||||
from dataclasses import replace
|
||||
|
|
@ -101,11 +120,15 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
|
|||
queue = OfflineQueue(os.environ.get("QUEUE_DB", "queue.db"), post)
|
||||
|
||||
pose = build_pose() # shared across cameras
|
||||
runtimes = []
|
||||
for backend, device, cam in parse_cameras(dict(os.environ), cfg.camera_id):
|
||||
|
||||
def make_runtime(cam, source):
|
||||
# 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))
|
||||
runtimes.append(PipelineRuntime(cam_cfg, build_source(backend, device), pose, queue))
|
||||
return PipelineRuntime(cam_cfg, source, pose, queue)
|
||||
|
||||
# A missing/unseated camera is skipped (logged), not fatal — the others still run.
|
||||
runtimes = build_runtimes(parse_cameras(dict(os.environ), cfg.camera_id),
|
||||
build_source, make_runtime)
|
||||
|
||||
run_loop(runtimes, client, queue, clock=now, sleep=time.sleep, iterations=None,
|
||||
frame_interval=1.0 / float(os.environ.get("TARGET_FPS", "12")),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"""TDD for multi-camera support: per-camera zone routing, a run_loop that steps
|
||||
N cameras per tick, and CAMERAS env parsing. All with fakes (no hardware)."""
|
||||
import pytest
|
||||
|
||||
from edge.zones import zones_from_config
|
||||
from edge.daemon import run_loop, parse_cameras
|
||||
from edge.daemon import run_loop, parse_cameras, build_runtimes
|
||||
|
||||
|
||||
def _cfg_two_cams():
|
||||
|
|
@ -73,10 +75,30 @@ def test_parse_cameras_multi_and_single():
|
|||
|
||||
|
||||
def test_parse_cameras_rejects_blank_entries_never_zero_cameras():
|
||||
import pytest
|
||||
# A non-empty-but-all-blank CAMERAS must fail loudly, never yield zero cameras
|
||||
# (a safety daemon monitoring nothing while looking healthy).
|
||||
with pytest.raises(ValueError):
|
||||
parse_cameras({"CAMERAS": " , "}, "cam-01")
|
||||
with pytest.raises(ValueError):
|
||||
parse_cameras({"CAMERAS": "csi:0"}, "cam-01") # wrong arity
|
||||
|
||||
|
||||
def test_build_runtimes_skips_a_camera_that_fails_to_start():
|
||||
# An unseated/absent CSI camera raises at construction; it must be skipped so the
|
||||
# working USB camera still runs (construction resilience, not just step isolation).
|
||||
def make_source(backend, device):
|
||||
if backend == "csi":
|
||||
raise RuntimeError("no CSI camera attached")
|
||||
return ("src", backend, device)
|
||||
logs = []
|
||||
rts = build_runtimes([("csi", "0", "cam-01"), ("uvc", "0", "cam-02")],
|
||||
make_source, lambda cam, src: (cam, src), log=logs.append)
|
||||
assert rts == [("cam-02", ("src", "uvc", "0"))]
|
||||
assert any("cam-01" in m for m in logs)
|
||||
|
||||
|
||||
def test_build_runtimes_raises_when_all_cameras_fail():
|
||||
def boom(*_a):
|
||||
raise RuntimeError("gone")
|
||||
with pytest.raises(RuntimeError):
|
||||
build_runtimes([("csi", "0", "c")], boom, lambda c, s: None, log=lambda m: None)
|
||||
|
|
|
|||
Loading…
Reference in a new issue