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

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
# Python
__pycache__/
*.py[cod]
venv/
.env
# IDE
.vscode/
.idea/

0
app/__init__.py Normal file
View File

35
app/config.py Normal file
View File

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

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

501
app/templates/viewer.html Normal file
View File

@@ -0,0 +1,501 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<title>Camera Viewer</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #000;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
overflow: hidden;
height: 100vh;
width: 100vw;
}
/* Grid View */
.grid-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 2px;
height: 100vh;
width: 100vw;
padding: 2px;
background: #111;
}
.camera-cell {
position: relative;
background: #1a1a1a;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s;
}
.camera-cell:active {
transform: scale(0.98);
}
.camera-cell video {
width: 100%;
height: 100%;
object-fit: cover;
}
.camera-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 8px 12px;
background: linear-gradient(transparent, rgba(0,0,0,0.8));
font-size: 14px;
font-weight: 500;
}
.camera-status {
position: absolute;
top: 8px;
right: 8px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 6px #22c55e;
}
.camera-status.offline {
background: #ef4444;
box-shadow: 0 0 6px #ef4444;
}
.camera-status.connecting {
background: #f59e0b;
box-shadow: 0 0 6px #f59e0b;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Fullscreen View */
.fullscreen-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #000;
z-index: 1000;
}
.fullscreen-overlay.active {
display: flex;
flex-direction: column;
}
.fullscreen-overlay video {
flex: 1;
width: 100%;
object-fit: contain;
}
.fullscreen-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background: rgba(0,0,0,0.8);
}
.fullscreen-title {
font-size: 18px;
font-weight: 600;
}
.fullscreen-close {
padding: 8px 16px;
background: #333;
border: none;
border-radius: 6px;
color: #fff;
font-size: 14px;
cursor: pointer;
}
/* Alert Banner */
.alert-banner {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
padding: 12px 20px;
background: #dc2626;
color: #fff;
font-weight: 600;
text-align: center;
z-index: 1001;
animation: slideDown 0.3s ease;
}
.alert-banner.active {
display: block;
}
@keyframes slideDown {
from { transform: translateY(-100%); }
to { transform: translateY(0); }
}
/* Connection Status */
.connection-status {
position: fixed;
bottom: 10px;
right: 10px;
padding: 6px 12px;
background: rgba(0,0,0,0.7);
border-radius: 4px;
font-size: 12px;
z-index: 100;
}
.connection-status.connected {
color: #22c55e;
}
.connection-status.disconnected {
color: #ef4444;
}
/* Tablet optimizations */
@media (max-width: 1024px) {
.grid-container {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(4, 1fr);
}
}
@media (max-width: 600px) {
.grid-container {
grid-template-columns: 1fr;
grid-template-rows: repeat(8, 1fr);
}
}
</style>
</head>
<body>
<!-- Alert Banner -->
<div id="alertBanner" class="alert-banner">
Person detected at Front Porch
</div>
<!-- Camera Grid -->
<div class="grid-container" id="cameraGrid">
{% for camera_id, camera_name in cameras.items() %}
<div class="camera-cell" data-camera="{{ camera_id }}" onclick="openFullscreen('{{ camera_id }}', '{{ camera_name }}')">
<video id="grid-{{ camera_id }}" autoplay muted playsinline></video>
<div class="camera-status connecting" id="status-{{ camera_id }}"></div>
<div class="camera-label">{{ camera_name }}</div>
</div>
{% endfor %}
</div>
<!-- Fullscreen Overlay -->
<div class="fullscreen-overlay" id="fullscreenOverlay">
<div class="fullscreen-header">
<span class="fullscreen-title" id="fullscreenTitle">Camera</span>
<button class="fullscreen-close" onclick="closeFullscreen()">Close</button>
</div>
<video id="fullscreenVideo" autoplay playsinline></video>
</div>
<!-- Connection Status -->
<div class="connection-status disconnected" id="connectionStatus">
Connecting...
</div>
<script>
const GO2RTC_HOST = '{{ go2rtc_host }}';
const ALERT_CAMERA = '{{ alert_camera }}';
const AUTO_DISMISS_SECONDS = {{ auto_dismiss_seconds }};
const cameras = {{ cameras | tojson }};
let currentFullscreenCamera = null;
let autoDismissTimer = null;
let peerConnections = {};
let eventSource = null;
// Initialize WebRTC streams for all cameras
async function initCameraStream(cameraId, videoElement) {
const statusEl = document.getElementById(`status-${cameraId}`);
try {
// Create WebRTC peer connection
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
peerConnections[cameraId] = pc;
pc.ontrack = (event) => {
videoElement.srcObject = event.streams[0];
statusEl.className = 'camera-status';
};
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') {
statusEl.className = 'camera-status offline';
// Attempt reconnect after 5 seconds
setTimeout(() => reconnectCamera(cameraId, videoElement), 5000);
}
};
// Add receive-only transceiver
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
// Create and set local description
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// Send offer to go2rtc
const response = await fetch(`http://${GO2RTC_HOST}/api/webrtc?src=${cameraId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: offer.sdp
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const answer = await response.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
} catch (error) {
console.error(`Failed to connect to ${cameraId}:`, error);
statusEl.className = 'camera-status offline';
// Retry after 10 seconds
setTimeout(() => reconnectCamera(cameraId, videoElement), 10000);
}
}
async function reconnectCamera(cameraId, videoElement) {
// Close existing connection
if (peerConnections[cameraId]) {
peerConnections[cameraId].close();
delete peerConnections[cameraId];
}
const statusEl = document.getElementById(`status-${cameraId}`);
statusEl.className = 'camera-status connecting';
await initCameraStream(cameraId, videoElement);
}
// Fullscreen management
function openFullscreen(cameraId, cameraName, isAlert = false) {
currentFullscreenCamera = cameraId;
const overlay = document.getElementById('fullscreenOverlay');
const video = document.getElementById('fullscreenVideo');
const title = document.getElementById('fullscreenTitle');
title.textContent = cameraName + (isAlert ? ' - PERSON DETECTED' : '');
overlay.classList.add('active');
// Start high-quality stream for fullscreen
initFullscreenStream(cameraId, video);
// Request browser fullscreen
if (document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen().catch(() => {});
}
// Start auto-dismiss timer if this is an alert
if (isAlert) {
clearTimeout(autoDismissTimer);
autoDismissTimer = setTimeout(() => {
closeFullscreen();
}, AUTO_DISMISS_SECONDS * 1000);
}
}
async function initFullscreenStream(cameraId, videoElement) {
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
pc.ontrack = (event) => {
videoElement.srcObject = event.streams[0];
};
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
try {
const response = await fetch(`http://${GO2RTC_HOST}/api/webrtc?src=${cameraId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: offer.sdp
});
const answer = await response.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
// Store for cleanup
peerConnections['fullscreen'] = pc;
} catch (error) {
console.error('Fullscreen stream error:', error);
}
}
function closeFullscreen() {
const overlay = document.getElementById('fullscreenOverlay');
const banner = document.getElementById('alertBanner');
const video = document.getElementById('fullscreenVideo');
overlay.classList.remove('active');
banner.classList.remove('active');
currentFullscreenCamera = null;
// Clean up fullscreen stream
if (peerConnections['fullscreen']) {
peerConnections['fullscreen'].close();
delete peerConnections['fullscreen'];
}
video.srcObject = null;
clearTimeout(autoDismissTimer);
// Exit browser fullscreen
if (document.exitFullscreen) {
document.exitFullscreen().catch(() => {});
}
}
// SSE Event handling
function connectEventSource() {
const statusEl = document.getElementById('connectionStatus');
eventSource = new EventSource('/events');
eventSource.onopen = () => {
statusEl.textContent = 'Connected';
statusEl.className = 'connection-status connected';
};
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'person_detected' && data.camera === ALERT_CAMERA) {
// Show alert banner
const banner = document.getElementById('alertBanner');
banner.classList.add('active');
// Auto-fullscreen the alert camera
openFullscreen(ALERT_CAMERA, cameras[ALERT_CAMERA], true);
// Play alert sound (optional)
playAlertSound();
}
};
eventSource.onerror = () => {
statusEl.textContent = 'Disconnected - Reconnecting...';
statusEl.className = 'connection-status disconnected';
eventSource.close();
// Reconnect after 3 seconds
setTimeout(connectEventSource, 3000);
};
}
function playAlertSound() {
// Create a simple beep sound
try {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800;
oscillator.type = 'sine';
gainNode.gain.value = 0.3;
oscillator.start();
setTimeout(() => oscillator.stop(), 200);
} catch (e) {
// Audio not available
}
}
// Keep screen awake (for tablets)
async function requestWakeLock() {
try {
if ('wakeLock' in navigator) {
await navigator.wakeLock.request('screen');
}
} catch (e) {
// Wake lock not available
}
}
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeFullscreen();
}
});
// Initialize on load
document.addEventListener('DOMContentLoaded', () => {
// Initialize all camera streams
for (const cameraId of Object.keys(cameras)) {
const videoEl = document.getElementById(`grid-${cameraId}`);
if (videoEl) {
initCameraStream(cameraId, videoEl);
}
}
// Connect to event stream
connectEventSource();
// Request wake lock
requestWakeLock();
});
// Handle visibility change (reconnect when tab becomes visible)
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
// Reconnect cameras if needed
for (const cameraId of Object.keys(cameras)) {
const statusEl = document.getElementById(`status-${cameraId}`);
if (statusEl && statusEl.classList.contains('offline')) {
const videoEl = document.getElementById(`grid-${cameraId}`);
reconnectCamera(cameraId, videoEl);
}
}
}
});
</script>
</body>
</html>

15
camera-viewer.service Normal file
View File

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

6
requirements.txt Normal file
View File

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

4
run.sh Normal file
View File

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