From c385be017c8585c996e36fb2ef8a8d16f2bc7e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Mon, 13 Jul 2026 23:47:20 +0900 Subject: [PATCH] =?UTF-8?q?fix(edge):=20resilient=20multi-camera=20startup?= =?UTF-8?q?=20=E2=80=94=20skip=20a=20camera=20that=20fails=20to=20construc?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- edge/daemon.py | 29 ++++++++++++++++++++++++++--- tests/test_multicam.py | 26 ++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 5 deletions(-) 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)