commit cebb9b6f288244a37a15c6a0bccdd0f1b68be0cc Author: chrisryn Date: Fri Jan 30 22:36:53 2026 -0600 Initial commit: Camera Viewer with Frigate alerts Features: - 8-camera WebRTC grid using go2rtc - MQTT subscription to Frigate events - Auto-fullscreen on person detection (Front_Porch) - SSE for real-time browser updates - Touch-friendly tablet interface - Wake lock to keep screen on - Auto-dismiss after 30 seconds diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc477cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Python +__pycache__/ +*.py[cod] +venv/ +.env + +# IDE +.vscode/ +.idea/ diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..34ec8b7 --- /dev/null +++ b/app/config.py @@ -0,0 +1,35 @@ +"""Camera Viewer Configuration""" +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # MQTT Settings (Home Assistant) + MQTT_HOST: str = "192.168.1.50" + MQTT_PORT: int = 1883 + MQTT_USER: str = "mqtt" + MQTT_PASSWORD: str = "11xpfcryan" + + # go2rtc Settings + GO2RTC_HOST: str = "192.168.1.241:1985" + + # Alert Settings + ALERT_CAMERA: str = "Front_Porch" + AUTO_DISMISS_SECONDS: int = 30 + + # Camera list (name: display_name) + CAMERAS: dict = { + "Front_Porch": "Front Porch", + "FPE": "Front Yard", + "Driveway": "Driveway", + "Driveway_door": "Driveway Door", + "Porch_Downstairs": "Porch Downstairs", + "Street_side": "Street Side", + "Backyard": "Backyard", + "House_side": "House Side", + } + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..77df950 --- /dev/null +++ b/app/main.py @@ -0,0 +1,149 @@ +""" +Camera Viewer - Live camera grid with Frigate event-driven fullscreen +""" +import asyncio +import json +import logging +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +import aiomqtt +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from .config import settings + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Event queue for SSE clients +event_queues: list[asyncio.Queue] = [] + +async def mqtt_listener(): + """Subscribe to Frigate MQTT events and broadcast to SSE clients""" + reconnect_interval = 5 + + while True: + try: + async with aiomqtt.Client( + hostname=settings.MQTT_HOST, + port=settings.MQTT_PORT, + username=settings.MQTT_USER, + password=settings.MQTT_PASSWORD, + identifier="camera-viewer" + ) as client: + logger.info(f"Connected to MQTT broker at {settings.MQTT_HOST}") + + # Subscribe to Frigate events + await client.subscribe("frigate/events") + await client.subscribe("frigate/+/person") # Per-camera person topic + + async for message in client.messages: + try: + payload = json.loads(message.payload.decode()) + + # Handle frigate/events topic + if message.topic.matches("frigate/events"): + event_type = payload.get("type") + after = payload.get("after", {}) + camera = after.get("camera") + label = after.get("label") + + # Check for person on alert camera + if (event_type in ["new", "update"] and + camera == settings.ALERT_CAMERA and + label == "person"): + + event = { + "type": "person_detected", + "camera": camera, + "score": after.get("score", 0), + "event_id": after.get("id") + } + logger.info(f"Person detected on {camera}") + + # Broadcast to all SSE clients + for queue in event_queues: + await queue.put(event) + + except json.JSONDecodeError: + pass + except Exception as e: + logger.error(f"Error processing MQTT message: {e}") + + except aiomqtt.MqttError as e: + logger.error(f"MQTT connection error: {e}. Reconnecting in {reconnect_interval}s...") + await asyncio.sleep(reconnect_interval) + except Exception as e: + logger.error(f"Unexpected error in MQTT listener: {e}") + await asyncio.sleep(reconnect_interval) + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Start MQTT listener on startup""" + mqtt_task = asyncio.create_task(mqtt_listener()) + logger.info("Camera Viewer started") + yield + mqtt_task.cancel() + try: + await mqtt_task + except asyncio.CancelledError: + pass + +app = FastAPI(title="Camera Viewer", lifespan=lifespan) +app.mount("/static", StaticFiles(directory="app/static"), name="static") +templates = Jinja2Templates(directory="app/templates") + + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + """Main camera grid view""" + return templates.TemplateResponse("viewer.html", { + "request": request, + "cameras": settings.CAMERAS, + "go2rtc_host": settings.GO2RTC_HOST, + "alert_camera": settings.ALERT_CAMERA, + "auto_dismiss_seconds": settings.AUTO_DISMISS_SECONDS + }) + + +@app.get("/events") +async def events() -> StreamingResponse: + """SSE endpoint for Frigate events""" + queue: asyncio.Queue = asyncio.Queue() + event_queues.append(queue) + + async def event_generator() -> AsyncGenerator[str, None]: + try: + # Send initial connection confirmation + yield f"data: {json.dumps({'type': 'connected'})}\n\n" + + while True: + event = await queue.get() + yield f"data: {json.dumps(event)}\n\n" + except asyncio.CancelledError: + pass + finally: + event_queues.remove(queue) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" + } + ) + + +@app.get("/health") +async def health(): + """Health check endpoint""" + return { + "status": "ok", + "mqtt_clients": len(event_queues), + "cameras": len(settings.CAMERAS) + } diff --git a/app/templates/viewer.html b/app/templates/viewer.html new file mode 100644 index 0000000..d440238 --- /dev/null +++ b/app/templates/viewer.html @@ -0,0 +1,501 @@ + + + + + + + + Camera Viewer + + + + +
+ Person detected at Front Porch +
+ + +
+ {% for camera_id, camera_name in cameras.items() %} +
+ +
+
{{ camera_name }}
+
+ {% endfor %} +
+ + +
+
+ Camera + +
+ +
+ + +
+ Connecting... +
+ + + + diff --git a/camera-viewer.service b/camera-viewer.service new file mode 100644 index 0000000..f6a856c --- /dev/null +++ b/camera-viewer.service @@ -0,0 +1,15 @@ +[Unit] +Description=Camera Viewer - Live camera grid with Frigate alerts +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/camera-viewer +ExecStart=/opt/camera-viewer/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8080 +Restart=always +RestartSec=5 +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4d66922 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.109.0 +uvicorn[standard]>=0.27.0 +aiomqtt>=2.0.0 +jinja2>=3.1.0 +pydantic-settings>=2.0.0 +python-multipart>=0.0.6 diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..b8991ac --- /dev/null +++ b/run.sh @@ -0,0 +1,4 @@ +#!/bin/bash +cd /opt/camera-viewer +source venv/bin/activate +uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload