diff --git a/edge/daemon.py b/edge/daemon.py index 036f2bd..c3f8788 100644 --- a/edge/daemon.py +++ b/edge/daemon.py @@ -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")), diff --git a/tests/test_multicam.py b/tests/test_multicam.py index 4fc6fb9..613cd4a 100644 --- a/tests/test_multicam.py +++ b/tests/test_multicam.py @@ -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)