build_runtimes() logs-and-skips a camera it cannot open, so a wrong /dev/video index or an unseated ribbon produced a green systemd unit that silently monitored one camera less. - edge/probe.py: opens each camera in CAMERAS, retries while the sensor settles, reports frame geometry, optionally saves preview PNGs, and exits non-zero with a backend-specific hint (v4l2-ctl / rpicam-hello) - setup_online.sh: new step [8/9] runs it against the installed package and the CAMERAS line actually written to edge.env, and refuses to enable the unit on failure. SKIP_CAMERA_CHECK=1 opts out. Tests: 174 pass, 2 skipped (7 new).
133 lines
4.9 KiB
Python
133 lines
4.9 KiB
Python
"""Camera probe: open every configured camera, grab a frame, report what came back.
|
|
|
|
Run by deploy/setup_online.sh BEFORE the service is enabled, so a miswired ribbon or a
|
|
USB camera that enumerated on a different /dev/video index fails during the install --
|
|
while an operator is still standing at the Pi -- instead of becoming a Restart=always
|
|
unit that monitors one camera less and looks perfectly healthy on the dashboard.
|
|
|
|
Standalone too:
|
|
/opt/move-detect/venv/bin/python -m edge.probe # uses CAMERAS from env
|
|
CAMERAS=uvc:2:cam-02 python -m edge.probe --save /tmp/probe # write preview PNGs
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass
|
|
|
|
from edge.daemon import parse_cameras
|
|
|
|
# A camera can open and still hand back nothing for the first few reads while the sensor
|
|
# settles (UVC auto-exposure, CSI pipeline start). Retry rather than fail a healthy camera.
|
|
_READ_ATTEMPTS = 8
|
|
_READ_DELAY_S = 0.25
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProbeResult:
|
|
camera_id: str
|
|
backend: str
|
|
device: str
|
|
ok: bool
|
|
detail: str
|
|
|
|
|
|
def _describe(frame) -> str:
|
|
shape = getattr(frame, "shape", None)
|
|
if not shape or len(shape) < 2:
|
|
return "frame of unknown shape"
|
|
h, w = shape[0], shape[1]
|
|
ch = shape[2] if len(shape) > 2 else 1
|
|
return f"{w}x{h}, {ch} channel(s)"
|
|
|
|
|
|
def _default_build_source(backend: str, device: str):
|
|
"""Same construction the daemon uses (edge/daemon.py build_source)."""
|
|
if backend == "csi":
|
|
from edge.adapters.picamera_source import PiCameraSource
|
|
return PiCameraSource(int(device))
|
|
from edge.adapters.uvc_source import UvcSource
|
|
return UvcSource(int(device))
|
|
|
|
|
|
def probe_one(backend: str, device: str, camera_id: str, *, build_source=None,
|
|
save_dir: str | None = None) -> ProbeResult:
|
|
"""Open one camera, read a frame, optionally save a preview PNG."""
|
|
build_source = build_source or _default_build_source
|
|
try:
|
|
source = build_source(backend, device)
|
|
except Exception as exc: # device missing, wrong index, picamera2 not installed
|
|
return ProbeResult(camera_id, backend, device, False, f"open failed: {exc}")
|
|
|
|
try:
|
|
for attempt in range(_READ_ATTEMPTS):
|
|
ok, frame = source.read()
|
|
if ok and frame is not None:
|
|
detail = _describe(frame)
|
|
if save_dir:
|
|
detail += f"; {_save_preview(frame, save_dir, camera_id)}"
|
|
return ProbeResult(camera_id, backend, device, True, detail)
|
|
if attempt < _READ_ATTEMPTS - 1:
|
|
time.sleep(_READ_DELAY_S)
|
|
return ProbeResult(camera_id, backend, device, False,
|
|
f"opened but returned no frame in {_READ_ATTEMPTS} reads")
|
|
finally:
|
|
close = getattr(source, "close", None) or getattr(source, "release", None)
|
|
if callable(close):
|
|
try:
|
|
close()
|
|
except Exception:
|
|
pass # a probe must never fail the install on a teardown hiccup
|
|
|
|
|
|
def _save_preview(frame, save_dir: str, camera_id: str) -> str:
|
|
try:
|
|
import cv2
|
|
os.makedirs(save_dir, exist_ok=True)
|
|
path = os.path.join(save_dir, f"{camera_id}.png")
|
|
cv2.imwrite(path, frame)
|
|
return f"preview {path}"
|
|
except Exception as exc:
|
|
return f"preview failed: {exc}"
|
|
|
|
|
|
def probe_all(env=None, *, build_source=None, save_dir: str | None = None) -> list[ProbeResult]:
|
|
env = dict(os.environ if env is None else env)
|
|
cams = parse_cameras(env, env.get("DEVICE_ID", "cam-01"))
|
|
return [probe_one(be, dev, cid, build_source=build_source, save_dir=save_dir)
|
|
for be, dev, cid in cams]
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
ap = argparse.ArgumentParser(description="probe every camera in CAMERAS")
|
|
ap.add_argument("--save", metavar="DIR", help="write a preview PNG per camera")
|
|
args = ap.parse_args(argv)
|
|
|
|
try:
|
|
results = probe_all(save_dir=args.save)
|
|
except ValueError as exc: # malformed CAMERAS -- a config error, not a camera fault
|
|
print(f"CAMERAS is invalid: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
for r in results:
|
|
mark = "OK " if r.ok else "FAIL"
|
|
print(f" [{mark}] {r.camera_id} {r.backend}:{r.device} -- {r.detail}")
|
|
|
|
failed = [r for r in results if not r.ok]
|
|
if failed:
|
|
print(f"{len(failed)}/{len(results)} camera(s) failed.", file=sys.stderr)
|
|
if any(r.backend == "uvc" for r in failed):
|
|
print(" USB cameras enumerate in boot order -- check `v4l2-ctl --list-devices` "
|
|
"and fix the index in CAMERAS.", file=sys.stderr)
|
|
if any(r.backend == "csi" for r in failed):
|
|
print(" CSI: check the ribbon seating/orientation and `rpicam-hello --list-cameras`.",
|
|
file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
raise SystemExit(main())
|