Phase 1 (Foundation): project skeleton, TOML config + Pydantic validation, MQTT bus wrapper, SQLite schema (9 tables), Click CLI, process supervisor. Phase 2 (Camera): RTSP capture via OpenCV, MOG2 motion detection with configurable sensitivity/zones, adaptive FPS recording (2fps idle/30fps motion) via FFmpeg subprocess, HLS live streaming, pre-motion ring buffer. Phase 3 (Web UI): Flask + Bootstrap 5 dark theme, 6 blueprints, Jinja2 templates (dashboard, kiosk 2x2 grid, events, sensors, recordings, settings), PWA with service worker + Web Push, full admin settings UI with config persistence. Remote Access: WireGuard tunnel configs, nginx reverse proxy with HLS caching + rate limiting, bandwidth-optimized remote HLS stream (426x240 @ 500kbps), DO droplet setup script, certbot TLS. 29 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
118 lines
3.8 KiB
Python
118 lines
3.8 KiB
Python
"""Camera manager — spawns and supervises per-camera worker processes."""
|
|
|
|
import logging
|
|
import time
|
|
from multiprocessing import Process
|
|
|
|
from vigilar.camera.worker import run_camera_worker
|
|
from vigilar.config import VigilarConfig
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class CameraWorkerHandle:
|
|
"""Handle for a single camera worker process."""
|
|
|
|
def __init__(self, camera_id: str, process: Process):
|
|
self.camera_id = camera_id
|
|
self.process = process
|
|
self.restart_count = 0
|
|
self.last_restart: float = 0
|
|
|
|
@property
|
|
def is_alive(self) -> bool:
|
|
return self.process.is_alive()
|
|
|
|
|
|
class CameraManager:
|
|
"""Manages all camera worker processes."""
|
|
|
|
def __init__(self, config: VigilarConfig):
|
|
self._config = config
|
|
self._workers: dict[str, CameraWorkerHandle] = {}
|
|
|
|
def start(self) -> None:
|
|
"""Start a worker process for each enabled camera."""
|
|
enabled = [c for c in self._config.cameras if c.enabled]
|
|
log.info("Starting %d camera workers", len(enabled))
|
|
|
|
for cam_cfg in enabled:
|
|
self._start_worker(cam_cfg.id)
|
|
|
|
def _start_worker(self, camera_id: str) -> None:
|
|
"""Start a single camera worker process."""
|
|
cam_cfg = next((c for c in self._config.cameras if c.id == camera_id), None)
|
|
if not cam_cfg:
|
|
log.error("Camera config not found: %s", camera_id)
|
|
return
|
|
|
|
process = Process(
|
|
target=run_camera_worker,
|
|
args=(
|
|
cam_cfg,
|
|
self._config.mqtt,
|
|
self._config.system.recordings_dir,
|
|
self._config.system.hls_dir,
|
|
self._config.remote if self._config.remote.enabled else None,
|
|
),
|
|
name=f"camera-{camera_id}",
|
|
daemon=True,
|
|
)
|
|
process.start()
|
|
self._workers[camera_id] = CameraWorkerHandle(camera_id, process)
|
|
log.info("Camera worker started: %s (pid=%d)", camera_id, process.pid)
|
|
|
|
def check_and_restart(self) -> None:
|
|
"""Check for crashed workers and restart them with backoff."""
|
|
for camera_id, handle in list(self._workers.items()):
|
|
if handle.is_alive:
|
|
continue
|
|
|
|
if handle.restart_count >= 10:
|
|
log.error("Camera %s exceeded max restarts, giving up", camera_id)
|
|
continue
|
|
|
|
backoff = min(2 ** handle.restart_count, 60)
|
|
elapsed = time.monotonic() - handle.last_restart
|
|
if elapsed < backoff:
|
|
continue
|
|
|
|
handle.restart_count += 1
|
|
handle.last_restart = time.monotonic()
|
|
log.warning("Restarting camera worker %s (attempt %d)", camera_id, handle.restart_count)
|
|
self._start_worker(camera_id)
|
|
|
|
def stop(self) -> None:
|
|
"""Stop all camera worker processes."""
|
|
log.info("Stopping %d camera workers", len(self._workers))
|
|
for handle in self._workers.values():
|
|
if handle.process.is_alive():
|
|
handle.process.terminate()
|
|
|
|
for handle in self._workers.values():
|
|
handle.process.join(timeout=5)
|
|
if handle.process.is_alive():
|
|
handle.process.kill()
|
|
handle.process.join(timeout=2)
|
|
|
|
self._workers.clear()
|
|
|
|
@property
|
|
def worker_count(self) -> int:
|
|
return len(self._workers)
|
|
|
|
@property
|
|
def alive_count(self) -> int:
|
|
return sum(1 for h in self._workers.values() if h.is_alive)
|
|
|
|
def get_status(self) -> dict:
|
|
"""Get status of all camera workers."""
|
|
return {
|
|
cid: {
|
|
"alive": h.is_alive,
|
|
"pid": h.process.pid if h.process else None,
|
|
"restart_count": h.restart_count,
|
|
}
|
|
for cid, h in self._workers.items()
|
|
}
|