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
This commit is contained in:
2026-01-30 22:36:53 -06:00
commit cebb9b6f28
8 changed files with 719 additions and 0 deletions

149
app/main.py Normal file
View File

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