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:
유창욱 2026-07-13 23:47:20 +09:00
parent db376a756c
commit c385be017c
2 changed files with 50 additions and 5 deletions

View file

@ -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")),

View file

@ -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)