vigilar/vigilar/camera/manager.py
Aaron D. Lee 845a85d618 Initial commit: Vigilar DIY home security system
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>
2026-04-02 23:11:27 -04:00

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()
}