UI/UX 점검에서 시작해 "이 제품이 안전 시스템으로서 무엇이 없나"까지 내려간 작업. 두 덩어리를 한 커밋으로 둔다 — 뒤쪽 기능들이 앞쪽에서 고친 화면 위에 얹혀 있다. ━━ 1부: UI/UX 감사 21건 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 동선 — 확인(ack) 경로가 위험했다 - 스트림 정렬을 '미확인 우선'에서 엄격 시간순으로 되돌린다. 확인 상태가 연대기를 뒤집어 사고 재구성이 불가능했고, 확인하면 그 행이 목록 밖으로 사라지며 아래 행이 밀려 올라와 같은 좌표를 두 번 누르면 다른 이벤트가 확인 처리됐다. 이제 제자리에서 '확인됨'으로 전환되고 확정 토스트가 뜬다. 처리 큐는 정렬이 아니라 뷰모드 필터로 분리 (전환해도 행끼리 상대 순서는 절대 안 바뀐다). - 실행취소는 넣지 않았다. acknowledged_by는 감사로그가 함께 남는 추가 전용 기록이라 무음 되돌리기가 안전 시스템에서 더 나쁜 속성이다. 대신 오탐·구역삭제를 2단계 확정으로. - 이벤트 상세에 확인/오탐 신설. 판단하는 화면에 판단 도구가 없었다. - 구역 설정에 장치·카메라 선택기. 시스템이 아는 값을 사람에게 묻고 있었고, 플레이스홀더(device-01)마저 실제와 다른 포맷을 가르쳤다. - 날짜 조회(키셋 커서 시딩). 사고 조사 진입로가 아예 없어 '더 보기'를 반복해야 했다. - 경보음 세션 음소거(새 위급 도착 시 자동 해제). 끌 수 없는 반복음은 안전 콘솔이 통째로 꺼지는 경로다. 전달 — 같은 단어가 화면마다 다른 숫자를 가리켰다 - KPI '미확인'(최근 200건)과 24시간 브리프 '미확인'이 40px 간격으로 충돌했다. KPI에 실제 집계 범위를 명시. 중대=위급+높음을 인라인 정의. - 페이지 명칭 3종·날짜 포맷 3종 통일. '확인 필요' 칩을 전 심각도로 확장 (그전엔 확인 여부가 먼 컬럼의 버튼 유무로만 구분됐다). - 표본 게이트: 오탐율 '100% (1/1)'이 '33% (5/15)'보다 위에 뜨던 것, 최다 3건짜리 시간패턴 히트맵, 6→8건에 붙던 빨간 ▲33%. 개선조치 효과는 푸아송 잡음 범위(√before)를 넘어야 방향을 주장한다 — 안전회의에서 한 번 반박당하면 카드 전체가 신뢰를 잃는다. - 카메라 위험 순위가 중대>0이면 전부 빨강이라 순위 노릇을 못 했다. 상대 기준으로. 시각 — 화면마다 완성도가 달랐다 - 구역 설정·이벤트 상세를 카드 셸로 재구성. 둘 다 스타일 없는 기본 폼이었고, 납품 심사에서 "덜 만든 티"는 정확히 거기서 났다. - 히트맵 토글은 aria-pressed만 있고 CSS 규칙이 없어 켜졌는지 눈으로 알 수 없었다. - 존 색을 팔레트와 정합(팔레트 어디에도 없던 보라 제거), 채움+라벨칩+협착 점선. 캔버스에 1px 외곽선만 그리면 실제 공장 프레임에서 사라진다. ━━ 2부: 안전 플랫폼 (마이그레이션 2026-08-09-safety-platform.sql) ━━━ 통보 — 이 제품의 정의에 해당하는 구멍 지금까지 감지는 브라우저 탭이 열려 있고 사람이 보고 있어야만 전달됐다. 새벽 3시에 넘어진 작업자는 DB에 행 하나를 남기고 아침을 맞았다. - 엣지 GPIO 경광등(edge/beacon.py). 인터넷·서버 없이 동작하는 유일한 경로라 어떤 현장이든 이것부터 설정한다. /api/config의 beacons로 배선을 받고, 하드웨어가 없거나 핀이 안 열리면 NullBeacon으로 떨어지되 그 사실은 반드시 로그로 남긴다. - 메일·웹훅 채널 + 크론 디스패처(tools/dispatch_alerts.php). 중복 발송은 읽고-쓰기가 아니라 (event,channel) UNIQUE로 막는다 — 크론 중첩 실행은 공유호스팅의 상수다. - 미확인 위급은 unacked_escalation 이벤트가 되어 상위 채널로 올라간다(event_uuid로 멱등). '아무도 보지 않았다'는 사실 자체를 사건으로 만든다. - 통보에 이미지는 싣지 않는다. 스냅샷은 PIPA 대상이고 메일링 리스트로 나가는 순간 인증 프록시가 지키던 통제를 벗어난다 — 링크만 보낸다. 조용한 실패 제거 - 카메라 화각 이동·가림·조도 저하 감지(detectors/integrity.py). 존 판정은 화각 고정을 전제로 서 있는데, 삼각대를 스치면 제한구역이 엉뚱한 바닥을 가리키면서 시스템은 계속 초록불이었다. numpy만 사용하고, 지나가는 사람과 구분하기 위해 지속시간을 요구한다. - 스토리지 여유 감시. 디스크가 차면 이미지 저장만 조용히 실패하고 보드는 멀쩡해 보인다. 끊겨 있던 배선 2건 - thresholds_from_config()가 정의만 있고 어디서도 호출되지 않았다. 서버에 표가 있고 /api/config가 성실히 실어 보내고 엣지가 파싱한 뒤 버렸다 — 관리자가 무엇을 바꿔도 장비는 부팅 시 edge.env 값으로 영원히 돌았다(edge/tuning.py). - 존·임계값 저장이 config_version을 올리지 않아 '설정 지연' 표시가 애초에 켜질 수 없었다. 지표는 있는데 그것을 켜는 사건이 없었다. 관리 화면 (그전에는 phpMyAdmin이 유일한 방법이었다) - 임계값 편집: 자유 텍스트가 아니라 카탈로그(범위·단위·설명)로 렌더한다. 범위 밖 값은 엣지가 기동 시 거부하는데 그때는 이미 공장 안 장비가 재시작 루프다. 키보드 앞에서 막는다. 목록에 없는 키는 저장 불가 — 장비가 조용히 무시할 값이 '설정됨'으로 보이면 안 된다. - 정비 억제: 사유 필수, 최대 8시간(한 교대), 자동 만료. 감지와 기록은 그대로 계속되고 통보만 보류되며 그 보류도 원장에 status='suppressed'로 남는다 — 조사에서 '경보가 났지만 의도적으로 withheld됐다'를 볼 수 있어야 한다. - 교대 인수인계, 위험성평가, 설비 성능 추이, 설치 점검(/setup — 실제 상태 조회 기반). 증거·규정 - 감사로그 해시체인. 공유호스팅은 append-only 권한을 줄 수 없으니 '막는' 대신 '숨길 수 없게' 만든다. 수정·삭제·우회삽입이 전부 탐지되고(tools/verify_audit_chain.php, 손상 시 exit 1), 체인 이전 행은 손상이 아니라 '검증 불가 구간'으로 따로 센다 — 정상 시스템이 매번 빨간불을 켜면 아무도 이 도구를 안 본다. - 사고조사 패키지 1클릭: 전후 타임라인 + 통보 이력(누구에게 닿았는가) + 위험요인 + 조치 이력 + 서명란. 인쇄 CSS로 그대로 제출 가능. 사고 다음 날 반나절을 쓸 사람은 없다. - 이벤트→조치 직접 기록, 현장별 롤업(전역 계정만). 운영 - 일괄 확인(위급은 의도적으로 제외·2단계 확정), 키보드 트리아지(J/K/A/F/Enter), 모바일 레이아웃(44px 타깃), 보존기간 정리(tools/maintain.php), 백업/복구(tools/backup.php — www 안 출력은 거부), 데모 시드(tools/seed_demo.php). - tools/는 전부 CLI 전용이고 납품 시 move_detect_app/tools/에 www 밖으로 배치된다. 미착수 4건 (모델·하드웨어가 선행 조건) - 보호구·중장비 근접·화재 감지는 AGPL 아닌 가중치 확보 + HEF 컴파일이 관문이다. 감지기 배선·이벤트 계약·심각도·쿨다운·테스트까지 만들고 기본 비활성으로 둔다 (edge/detectors/pending.py). enabled=True인데 model=None이면 아무것도 하지 않도록 명시적으로 막았다 — "곧 됩니다" 상태로 켜 두면 현장은 감지되고 있다고 믿는다. - PLC 접점 협착 게이팅도 같은 이유로 인터페이스만. 검증: pytest 통과(skip 3 = 하드웨어·환경) / PHPUnit 221 (skip 0, +59). Chrome 실렌더로 신규 4화면 + 임계값 저장→config_version 증가→감사 기록→체인 검증→ 행 변조→탐지(exit 1) 전 경로 확인. cafe24 번들 + Pi 오프라인 번들(aarch64 휠 11개) 재생성, 소스 패리티 통과. 보안 자체점검: SQL 문자열 보간 0, innerHTML 0, 상태 변경 핸들러 전부 CSRF+RBAC.
303 lines
16 KiB
Python
303 lines
16 KiB
Python
import os
|
|
|
|
|
|
# Rate-limit for the swallowed-exception log: a camera failing every frame must be VISIBLE
|
|
# (not a silent green unit) but must not spam the journal. Log the 1st of a run of consecutive
|
|
# failures, then every LOG_EVERY-th, and again after any recovery.
|
|
_LOG_EVERY = 300
|
|
|
|
|
|
def _beacon_signature(beacons):
|
|
"""Identity of a beacon set, so a sync only re-opens GPIO when the wiring actually changed.
|
|
Rebuilding unconditionally would blink every strobe on every sync (every ~150 ticks)."""
|
|
return tuple(sorted((getattr(b, "pin", 0), getattr(b, "mode", ""), getattr(b, "hold_s", 0.0),
|
|
getattr(b, "active_high", False), getattr(b, "min_rank", 0))
|
|
for b in (beacons or [])))
|
|
|
|
|
|
def _default_log(msg, exc=None): # pragma: no cover (thin stderr wrapper)
|
|
import sys
|
|
import traceback
|
|
print(msg, file=sys.stderr)
|
|
if exc is not None:
|
|
traceback.print_exception(type(exc), exc, exc.__traceback__, file=sys.stderr)
|
|
|
|
|
|
def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
|
|
sync_every=150, heartbeat_every=900, flush_every=300,
|
|
frame_interval=0.0, zones_for=None, log=None,
|
|
heartbeat_interval_s=None, health_fn=None, configured_cameras=None,
|
|
notify=None, beacons=None, build_beacons_fn=None):
|
|
# `runtime` may be a single PipelineRuntime or a list of them (multi-camera). All
|
|
# cameras share this loop, one shared clock read per tick, and the periodic
|
|
# sync/heartbeat/flush. A per-camera step exception is counted and rate-limit-logged,
|
|
# never fatal — but it is no longer SILENT (a permanently broken pipeline used to look
|
|
# perfectly green).
|
|
runtimes = list(runtime) if isinstance(runtime, (list, tuple)) else [runtime]
|
|
if zones_for is None:
|
|
from edge.zones import zones_from_config
|
|
zones_for = lambda cfg, rt: zones_from_config(cfg)
|
|
if log is None:
|
|
log = _default_log
|
|
if health_fn is None:
|
|
from edge.health import read_health
|
|
health_fn = read_health
|
|
if notify is None:
|
|
from edge.watchdog import ping as notify
|
|
# The ping happens at the TOP of each tick, before any camera work. That is the point: it
|
|
# proves the loop is turning. Pinging after the work would still ping while a camera step
|
|
# blocks forever, which is the exact failure the watchdog exists to catch.
|
|
# Local beacons (siren / strobe on the node's own GPIO). This is the ONE notification path
|
|
# that survives a dead uplink, so it fires here — the instant this loop sees the event —
|
|
# rather than waiting for the server-side dispatcher. Configured from /api/config, so the
|
|
# list is rebuilt on sync; None means "not built yet", [] means "built, nothing wired".
|
|
if beacons is None:
|
|
beacons = []
|
|
if build_beacons_fn is None:
|
|
from edge.beacon import build_beacons as build_beacons_fn
|
|
from edge.beacon import fire_all as _fire_beacons
|
|
counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0,
|
|
"beacon_fires": 0, "tuned": 0}
|
|
streak = {} # id(rt)/"periodic" -> consecutive-error count, for rate-limited logging
|
|
|
|
def note(key, label, exc=None):
|
|
counters["errors"] += 1
|
|
n = streak.get(key, 0) + 1
|
|
streak[key] = n
|
|
if n == 1 or n % _LOG_EVERY == 0:
|
|
if exc is not None:
|
|
log(f"{label}: {type(exc).__name__}: {exc} (consecutive #{n})", exc)
|
|
else:
|
|
log(f"{label} (consecutive #{n})", None)
|
|
|
|
beat_anchor = None # (wall_time, frames) at the previous heartbeat, for measured fps
|
|
last_beat = None # wall time of the last heartbeat, for the time-based cadence
|
|
reported_version = None # last config version the server told us; echoed in heartbeats
|
|
i = 0
|
|
while iterations is None or i < iterations:
|
|
i += 1
|
|
try:
|
|
notify()
|
|
except Exception as e:
|
|
note("watchdog", "watchdog ping failed", e)
|
|
now = clock() # one timestamp per tick, shared across cameras
|
|
if beat_anchor is None:
|
|
beat_anchor = (now, 0)
|
|
if last_beat is None:
|
|
last_beat = now
|
|
for rt in runtimes:
|
|
try:
|
|
produced = rt.step(now)
|
|
except Exception as e:
|
|
cam = getattr(rt, "camera_id", "?")
|
|
note(id(rt), f"camera {cam} step failed", e)
|
|
continue
|
|
if produced is None:
|
|
# step() returned no frame (unplugged/browned-out camera). Count it toward the
|
|
# erroring streak instead of as a healthy frame — otherwise a dead camera ticks
|
|
# frames++ forever and reports fps~=target with cameras_erroring=0 (a silent
|
|
# monitoring outage that looks green). A single dropped frame self-clears on the
|
|
# next good frame, so only SUSTAINED starvation shows as erroring.
|
|
cam = getattr(rt, "camera_id", "?")
|
|
note(id(rt), f"camera {cam} produced no frame")
|
|
else:
|
|
counters["frames"] += 1
|
|
streak.pop(id(rt), None) # recovered — reset the streak
|
|
# Sound the local beacon on this tick's events. Wrapped: a wiring fault must
|
|
# never take down the detection loop — a node that stopped watching because
|
|
# its strobe failed would be the worst possible trade.
|
|
if produced and beacons:
|
|
try:
|
|
counters["beacon_fires"] += _fire_beacons(beacons, produced, now)
|
|
except Exception as e:
|
|
note("beacon", "beacon fire failed", e)
|
|
# Release latched beacons whose hold time has elapsed.
|
|
for b in beacons:
|
|
try:
|
|
b.tick(now)
|
|
except Exception as e:
|
|
note("beacon", "beacon tick failed", e)
|
|
try:
|
|
if sync_every and i % sync_every == 0:
|
|
cfg = client.fetch_config()
|
|
if cfg is not None:
|
|
for rt in runtimes:
|
|
rt.apply_zones(zones_for(cfg, rt))
|
|
# Thresholds too. The server has always SENT these; nothing ever applied
|
|
# them, so an administrator's retuning never reached the device.
|
|
thresholds = cfg.get("thresholds") if isinstance(cfg, dict) else None
|
|
if isinstance(thresholds, dict) and thresholds:
|
|
for rt in runtimes:
|
|
applier = getattr(rt, "apply_thresholds", None)
|
|
if applier is None:
|
|
continue
|
|
applied = applier(thresholds, log=lambda m: log(m, None))
|
|
counters["tuned"] += len(applied)
|
|
# Rebuild the local beacons from the published wiring. Doing it on sync
|
|
# (not only at boot) is what lets an administrator turn a site's strobe on
|
|
# without anyone walking to the node.
|
|
try:
|
|
rebuilt = build_beacons_fn(cfg if isinstance(cfg, dict) else {},
|
|
log=lambda m: log(m, None))
|
|
if _beacon_signature(rebuilt) != _beacon_signature(beacons):
|
|
for b in beacons:
|
|
b.release()
|
|
beacons = rebuilt
|
|
except Exception as e:
|
|
note("beacon", "beacon rebuild failed", e)
|
|
# Remember the config version so heartbeats can echo it back — the server's
|
|
# config-drift indicator is meaningless until the device reports what it runs.
|
|
if isinstance(cfg, dict) and cfg.get("version") is not None:
|
|
reported_version = int(cfg["version"])
|
|
counters["syncs"] += 1
|
|
# Heartbeat cadence: time-based when heartbeat_interval_s is set (production), so the
|
|
# interval is predictable regardless of per-tick cost (CPU MoveNet, multi-cam, thermal
|
|
# throttle can push a tick past 100ms) — an iteration count cannot guarantee a wall-clock
|
|
# cadence, and the server marks a device offline on ELAPSED time. Falls back to the
|
|
# iteration count when unset (used by the deterministic unit tests).
|
|
if heartbeat_interval_s:
|
|
do_beat = (now - last_beat) >= heartbeat_interval_s
|
|
else:
|
|
do_beat = bool(heartbeat_every) and (i % heartbeat_every == 0)
|
|
if do_beat:
|
|
# Measured fps over the window since the last beat, and per-camera health, so
|
|
# the server can tell a live device from a degraded/dead one (was hardcoded 0.0
|
|
# + a frames counter that ticked even when no frame was read).
|
|
t0, f0 = beat_anchor
|
|
elapsed = now - t0
|
|
fps = (counters["frames"] - f0) / elapsed if elapsed > 0 else 0.0
|
|
# cameras_total is the CONFIGURED count, not just the started one: a camera that
|
|
# failed to construct at boot (wrong /dev index, unseated ribbon) is skipped by
|
|
# build_runtimes and would otherwise vanish from the heartbeat entirely — the
|
|
# server could never see that a configured camera is missing. Count the not-started
|
|
# cameras as erroring so a 4-cam device that lost one reports 4 total / 1 erroring.
|
|
total = configured_cameras if configured_cameras is not None else len(runtimes)
|
|
not_started = max(0, total - len(runtimes))
|
|
erroring = not_started + sum(1 for rt in runtimes if id(rt) in streak)
|
|
metrics = {"fps": round(fps, 2), "frames": counters["frames"],
|
|
"cameras_total": total, "cameras_erroring": erroring,
|
|
"errors": counters["errors"], "queue_pending": queue.pending()}
|
|
# Pi thermal telemetry (None off-Pi or on failure → field omitted, never a fake 0).
|
|
health = health_fn()
|
|
if health.get("temp") is not None:
|
|
metrics["temp"] = health["temp"]
|
|
if health.get("throttle") is not None:
|
|
metrics["throttle"] = health["throttle"]
|
|
if reported_version is not None:
|
|
metrics["config_version"] = reported_version
|
|
if client.send_heartbeat(metrics):
|
|
counters["beats"] += 1
|
|
beat_anchor = (now, counters["frames"])
|
|
last_beat = now
|
|
if flush_every and i % flush_every == 0:
|
|
queue.flush()
|
|
counters["flushes"] += 1
|
|
streak.pop("periodic", None)
|
|
except Exception as e:
|
|
note("periodic", "periodic op (sync/heartbeat/flush) failed", e)
|
|
if frame_interval:
|
|
sleep(frame_interval)
|
|
return counters
|
|
|
|
|
|
def parse_cameras(env, default_camera_id):
|
|
"""CAMERAS='backend:device:camera_id,...' -> [(backend, device, camera_id), ...].
|
|
When CAMERAS is unset, fall back to a single camera from
|
|
CAMERA_BACKEND + {UVC,CSI}_DEVICE (using default_camera_id)."""
|
|
raw = (env.get("CAMERAS") or "").strip()
|
|
if raw:
|
|
cams = []
|
|
for spec in raw.split(","):
|
|
spec = spec.strip()
|
|
if not spec:
|
|
continue
|
|
parts = [p.strip() for p in spec.split(":")]
|
|
if len(parts) != 3:
|
|
raise ValueError(f"bad CAMERAS entry {spec!r} (want backend:device:camera_id)")
|
|
cams.append((parts[0].lower(), parts[1], parts[2]))
|
|
if not cams:
|
|
raise ValueError("CAMERAS is set but has no valid entries") # never monitor zero cameras
|
|
return cams
|
|
backend = (env.get("CAMERA_BACKEND") or "uvc").lower()
|
|
device = env.get("CSI_DEVICE" if backend == "csi" else "UVC_DEVICE") or "0"
|
|
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
|
|
from edge.config import (frame_interval_from_env, load_config, queue_db_path,
|
|
require_upload_config)
|
|
from edge.serverclient import ServerClient
|
|
from edge.alert import PostClient
|
|
from edge.queue import OfflineQueue
|
|
from edge.runtime import PipelineRuntime
|
|
from edge.zones import zones_from_config
|
|
|
|
def build_source(backend, device):
|
|
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 build_pose():
|
|
# POSE_BACKEND: "hailo" (Pi5 + Hailo-8, needs HAILO_HEF) or CPU "movenet"
|
|
# (needs MOVENET_ONNX). One shared adapter serves every camera (the Hailo
|
|
# device / onnxruntime session is reused per frame, round-robin across cams).
|
|
if os.environ.get("POSE_BACKEND", "movenet").lower() == "hailo":
|
|
from edge.adapters.hailo_pose import HailoPoseAdapter
|
|
return HailoPoseAdapter(os.environ["HAILO_HEF"])
|
|
from edge.adapters.movenet_pose import MoveNetPoseAdapter
|
|
return MoveNetPoseAdapter(os.environ["MOVENET_ONNX"])
|
|
|
|
cfg = load_config(dict(os.environ))
|
|
require_upload_config(cfg) # fail loud now, not silently upload nothing forever
|
|
nonce = lambda: os.urandom(8).hex()
|
|
# Two clocks, deliberately: the loop/detector timers run on a MONOTONIC clock so an NTP step
|
|
# (a Pi 5 has no RTC battery; it boots on stale fake-hwclock time until chrony corrects it)
|
|
# cannot instantly satisfy immobile_s, expire every track, or reset an immobility countdown.
|
|
# Wall time is used only where a real timestamp is required: the HMAC X-Timestamp headers,
|
|
# which the server validates against its own wall clock, and Event.occurred_at.
|
|
mono = time.monotonic
|
|
wall = time.time
|
|
base = cfg.cafe24_url.rstrip("/")
|
|
client = ServerClient(base, cfg.device_id, cfg.secret, nonce, wall)
|
|
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, wall)
|
|
queue = OfflineQueue(queue_db_path(dict(os.environ)), post)
|
|
|
|
pose = build_pose() # shared across cameras
|
|
|
|
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))
|
|
return PipelineRuntime(cam_cfg, source, pose, queue, wall_clock=wall)
|
|
|
|
# A missing/unseated camera is skipped (logged), not fatal — the others still run.
|
|
cameras = parse_cameras(dict(os.environ), cfg.camera_id)
|
|
runtimes = build_runtimes(cameras, build_source, make_runtime)
|
|
|
|
run_loop(runtimes, client, queue, clock=mono, sleep=time.sleep, iterations=None,
|
|
frame_interval=frame_interval_from_env(dict(os.environ)),
|
|
heartbeat_interval_s=30, # predictable cadence; server marks offline after 120s (4x)
|
|
configured_cameras=len(cameras), # so a camera that failed to start stays visible
|
|
zones_for=lambda c, rt: zones_from_config(c, rt.camera_id))
|