7 Commits
master ... dev

Author SHA1 Message Date
root
898af6022b Improve stream reliability with stall detection and buffer management
Add video stall detection that monitors currentTime and auto-reconnects
after 15s of frozen video. Add MSE SourceBuffer trimming to prevent
QuotaExceededError from killing streams silently. Handle appendBuffer
errors with safe wrapper and SourceBuffer error listener. Wait for ICE
gathering before sending WebRTC offers. Add go2rtc stream availability
endpoint. Improve backend proxy error logging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:00:13 -06:00
root
c39ec4e954 Add Prometheus exporter for go2rtc and Frigate stream health
Standalone Python exporter that polls go2rtc /api/streams and Frigate
/api/stats every 15s, tracking bytes_recv deltas to detect stalled
streams. Exposes metrics on port 9199 including stream_up,
bytes_per_second, consumers, and camera_fps.
2026-03-05 12:27:52 -06:00
root
f26acc409a Fix WebSocket proxy ping timeout killing MSE streams
The websockets library defaults to ping_interval=20, ping_timeout=20.
go2rtc doesn't respond to these pings, so the upstream WebSocket was
being killed after ~50 seconds causing all camera streams to freeze.

Disabled ping_interval and ping_timeout on the upstream connection.
2026-02-26 16:01:22 -06:00
root
23ca4ee742 Fix stream freezing and alert camera name matching
- Add auto-reconnect with exponential backoff to MSE and WebRTC streams
  when connections drop, preventing permanent freezes in grid view
- Add onDisconnect callback to Go2RTCWebRTC (connectionstate monitoring)
  and Go2RTCMSE (WebSocket close detection)
- Fix alert camera names to use Frigate names (Front_Porch, etc.)
  instead of display names which never matched MQTT events
- Disable offline WyzePanV3 camera
2026-02-26 15:55:44 -06:00
root
b630ba0337 Fix streaming: MSE-first with go2rtc init protocol
- Switch from WebRTC-first to MSE-first streaming (more reliable
  across all camera types including high-res IP cameras)
- Send required {"type":"mse"} init message to go2rtc WebSocket
- Fix infinite re-render loop in configStore (pre-compute enabled
  cameras instead of deriving in selector)
- Fix mqtt_bridge global variable scope in broadcast()
- Add React ErrorBoundary for visible crash reporting
- Remove unused go2rtcUrl dependency from useStream hook
2026-02-25 22:36:13 -06:00
root
ba2824ec56 Add go2rtc proxy to fix CORS-blocked WebRTC/MSE streams 2026-02-25 22:11:56 -06:00
root
da5637fbdb Fix systemd service working directory for module imports 2026-02-25 22:04:17 -06:00
20 changed files with 668 additions and 106 deletions

View File

@@ -10,6 +10,7 @@ from config import load_config
from mqtt_bridge import start_mqtt from mqtt_bridge import start_mqtt
from routes.config_routes import router as config_router from routes.config_routes import router as config_router
from routes.ws_routes import router as ws_router from routes.ws_routes import router as ws_router
from routes.go2rtc_proxy import router as go2rtc_router
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -29,6 +30,7 @@ app = FastAPI(title="Camera Viewer", lifespan=lifespan)
app.include_router(config_router) app.include_router(config_router)
app.include_router(ws_router) app.include_router(ws_router)
app.include_router(go2rtc_router)
# Serve frontend static files # Serve frontend static files
if FRONTEND_DIR.exists(): if FRONTEND_DIR.exists():

View File

@@ -23,6 +23,7 @@ def unregister_ws_client(ws):
async def broadcast(message: dict): async def broadcast(message: dict):
global _ws_clients
data = json.dumps(message) data = json.dumps(message)
disconnected = set() disconnected = set()
for ws in _ws_clients: for ws in _ws_clients:
@@ -30,6 +31,7 @@ async def broadcast(message: dict):
await ws.send_text(data) await ws.send_text(data)
except Exception: except Exception:
disconnected.add(ws) disconnected.add(ws)
if disconnected:
_ws_clients -= disconnected _ws_clients -= disconnected

View File

@@ -4,3 +4,4 @@ aiomqtt==2.3.0
pyyaml==6.0.2 pyyaml==6.0.2
pydantic==2.9.2 pydantic==2.9.2
websockets==13.1 websockets==13.1
httpx==0.28.1

View File

@@ -0,0 +1,90 @@
import asyncio
import logging
import httpx
import websockets
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect, Response
from config import get_config
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/go2rtc")
@router.get("/streams")
async def get_streams():
"""Fetch available streams from go2rtc."""
config = get_config()
target = f"{config.go2rtc.url}/api/streams"
try:
async with httpx.AsyncClient() as client:
resp = await client.get(target, timeout=5.0)
return resp.json()
except Exception:
logger.error("Failed to fetch go2rtc streams from %s", target)
return {}
@router.post("/webrtc")
async def proxy_webrtc(request: Request, src: str):
"""Proxy WebRTC SDP exchange to go2rtc."""
config = get_config()
target = f"{config.go2rtc.url}/api/webrtc?src={src}"
body = await request.body()
async with httpx.AsyncClient() as client:
resp = await client.post(
target,
content=body,
headers={"Content-Type": "application/sdp"},
timeout=10.0,
)
return Response(
content=resp.content,
status_code=resp.status_code,
media_type="application/sdp",
)
@router.websocket("/ws")
async def proxy_mse_ws(ws: WebSocket, src: str):
"""Proxy MSE WebSocket to go2rtc."""
config = get_config()
go2rtc_ws_url = config.go2rtc.url.replace("http", "ws")
target = f"{go2rtc_ws_url}/api/ws?src={src}"
await ws.accept()
try:
async with websockets.connect(
target,
ping_interval=None,
ping_timeout=None,
close_timeout=5,
) as upstream:
async def forward_to_client():
async for msg in upstream:
if isinstance(msg, bytes):
await ws.send_bytes(msg)
else:
await ws.send_text(msg)
async def forward_to_upstream():
while True:
data = await ws.receive_text()
await upstream.send(data)
await asyncio.gather(
forward_to_client(),
forward_to_upstream(),
)
except WebSocketDisconnect:
logger.debug("Client disconnected from MSE proxy for %s", src)
except websockets.ConnectionClosed:
logger.debug("Upstream go2rtc closed for %s", src)
except ConnectionRefusedError:
logger.error("Cannot reach go2rtc at %s for stream %s", target, src)
except Exception:
logger.exception("MSE proxy error for stream %s", src)

View File

@@ -67,7 +67,10 @@ alerts:
enabled: true enabled: true
auto_dismiss_seconds: 30 auto_dismiss_seconds: 30
suppression_seconds: 60 suppression_seconds: 60
cameras: [] cameras:
- "Front_Porch"
- "Porch_Downstairs"
- "Driveway_door"
detection_types: detection_types:
- "person" - "person"

View File

@@ -0,0 +1,32 @@
import React from 'react';
interface State {
error: Error | null;
}
export class ErrorBoundary extends React.Component<React.PropsWithChildren, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
render() {
if (this.state.error) {
return (
<div style={{ padding: 40, color: '#ef4444', background: '#0f0f0f', height: '100vh', fontFamily: 'monospace' }}>
<h1 style={{ fontSize: 24, marginBottom: 16 }}>App Error</h1>
<pre style={{ whiteSpace: 'pre-wrap', color: '#f59e0b' }}>{this.state.error.message}</pre>
<pre style={{ whiteSpace: 'pre-wrap', color: '#9ca3af', marginTop: 8, fontSize: 12 }}>{this.state.error.stack}</pre>
<button
onClick={() => window.location.reload()}
style={{ marginTop: 20, padding: '8px 16px', background: '#3b82f6', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer' }}
>
Reload
</button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -6,7 +6,7 @@ import { CameraPlayer } from '@/components/player/CameraPlayer';
export function AlertPopup() { export function AlertPopup() {
const { activeAlert, dismissAlert } = useAlertStore(); const { activeAlert, dismissAlert } = useAlertStore();
const config = useConfigStore((s) => s.config); const config = useConfigStore((s) => s.config);
const cameras = useConfigStore((s) => s.enabledCameras()); const cameras = useConfigStore((s) => s.cameras);
const [countdown, setCountdown] = useState(30); const [countdown, setCountdown] = useState(30);
const autoDismiss = config?.alerts.auto_dismiss_seconds ?? 30; const autoDismiss = config?.alerts.auto_dismiss_seconds ?? 30;

View File

@@ -4,7 +4,7 @@ import { CameraGridCell } from './CameraGridCell';
const STAGGER_MS = 200; const STAGGER_MS = 200;
export function CameraGrid() { export function CameraGrid() {
const cameras = useConfigStore((s) => s.enabledCameras()); const cameras = useConfigStore((s) => s.cameras);
const gridConfig = useConfigStore((s) => s.config?.grid); const gridConfig = useConfigStore((s) => s.config?.grid);
const count = cameras.length; const count = cameras.length;

View File

@@ -1,5 +1,4 @@
import { useStream } from '@/hooks/useStream'; import { useStream } from '@/hooks/useStream';
import { useConfigStore } from '@/stores/configStore';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import type { CameraConfig } from '@/types/config'; import type { CameraConfig } from '@/types/config';
@@ -9,12 +8,10 @@ interface CameraGridCellProps {
} }
export function CameraGridCell({ camera, delayMs }: CameraGridCellProps) { export function CameraGridCell({ camera, delayMs }: CameraGridCellProps) {
const go2rtcUrl = useConfigStore((s) => s.config?.go2rtc.url ?? '');
const setFullscreen = useUIStore((s) => s.setFullscreenCamera); const setFullscreen = useUIStore((s) => s.setFullscreenCamera);
const { videoRef, isConnecting, error, retry } = useStream({ const { videoRef, isConnecting, error, retry } = useStream({
streamName: camera.name, streamName: camera.name,
go2rtcUrl,
delayMs, delayMs,
}); });
@@ -36,7 +33,7 @@ export function CameraGridCell({ camera, delayMs }: CameraGridCellProps) {
<div className="absolute inset-0 flex items-center justify-center bg-dark-primary/80"> <div className="absolute inset-0 flex items-center justify-center bg-dark-primary/80">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" /> <div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
<span className="text-xs text-gray-500">Connecting...</span> <span className="text-xs text-gray-400">{camera.display_name}</span>
</div> </div>
</div> </div>
)} )}
@@ -48,7 +45,8 @@ export function CameraGridCell({ camera, delayMs }: CameraGridCellProps) {
<svg className="w-6 h-6 text-status-error" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-6 h-6 text-status-error" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg> </svg>
<span className="text-xs text-gray-500">Offline</span> <span className="text-xs text-gray-400">{camera.display_name}</span>
<span className="text-xs text-status-error">{error}</span>
<button onClick={(e) => { e.stopPropagation(); retry(); }} className="text-xs text-accent hover:underline"> <button onClick={(e) => { e.stopPropagation(); retry(); }} className="text-xs text-accent hover:underline">
Retry Retry
</button> </button>
@@ -57,11 +55,13 @@ export function CameraGridCell({ camera, delayMs }: CameraGridCellProps) {
)} )}
{/* Label */} {/* Label */}
{!isConnecting && !error && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent px-2 py-1.5"> <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent px-2 py-1.5">
<span className="text-xs font-medium text-white/90 truncate block"> <span className="text-xs font-medium text-white/90 truncate block">
{camera.display_name} {camera.display_name}
</span> </span>
</div> </div>
)}
</div> </div>
); );
} }

View File

@@ -1,5 +1,4 @@
import { useStream } from '@/hooks/useStream'; import { useStream } from '@/hooks/useStream';
import { useConfigStore } from '@/stores/configStore';
import type { CameraConfig } from '@/types/config'; import type { CameraConfig } from '@/types/config';
interface CameraPlayerProps { interface CameraPlayerProps {
@@ -9,11 +8,8 @@ interface CameraPlayerProps {
} }
export function CameraPlayer({ camera, className = '', showLabel = true }: CameraPlayerProps) { export function CameraPlayer({ camera, className = '', showLabel = true }: CameraPlayerProps) {
const go2rtcUrl = useConfigStore((s) => s.config?.go2rtc.url ?? '');
const { videoRef, isConnecting, error, retry } = useStream({ const { videoRef, isConnecting, error, retry } = useStream({
streamName: camera.name, streamName: camera.name,
go2rtcUrl,
}); });
return ( return (

View File

@@ -5,7 +5,7 @@ import { CameraPlayer } from './CameraPlayer';
export function FullscreenView() { export function FullscreenView() {
const { fullscreenCamera, setFullscreenCamera } = useUIStore(); const { fullscreenCamera, setFullscreenCamera } = useUIStore();
const cameras = useConfigStore((s) => s.enabledCameras()); const cameras = useConfigStore((s) => s.cameras);
const currentIdx = cameras.findIndex((c) => c.name === fullscreenCamera); const currentIdx = cameras.findIndex((c) => c.name === fullscreenCamera);
const camera = currentIdx >= 0 ? cameras[currentIdx] : null; const camera = currentIdx >= 0 ? cameras[currentIdx] : null;

View File

@@ -1,9 +1,14 @@
import { useEffect, useRef, useState, useCallback } from 'react'; import { useEffect, useRef, useState, useCallback } from 'react';
import { Go2RTCWebRTC, Go2RTCMSE } from '@/services/go2rtc'; import { Go2RTCWebRTC, Go2RTCMSE } from '@/services/go2rtc';
const RECONNECT_DELAY = 3000;
const MAX_RECONNECT_DELAY = 30000;
const MSE_READY_TIMEOUT = 5000;
const STALL_CHECK_INTERVAL = 5000;
const STALL_THRESHOLD = 3; // consecutive stall checks before reconnecting
interface UseStreamOptions { interface UseStreamOptions {
streamName: string; streamName: string;
go2rtcUrl: string;
delayMs?: number; delayMs?: number;
enabled?: boolean; enabled?: boolean;
} }
@@ -15,10 +20,16 @@ interface UseStreamResult {
retry: () => void; retry: () => void;
} }
export function useStream({ streamName, go2rtcUrl, delayMs = 0, enabled = true }: UseStreamOptions): UseStreamResult { export function useStream({ streamName, delayMs = 0, enabled = true }: UseStreamOptions): UseStreamResult {
const videoRef = useRef<HTMLVideoElement>(null!); const videoRef = useRef<HTMLVideoElement>(null!);
const webrtcRef = useRef<Go2RTCWebRTC | null>(null);
const mseRef = useRef<Go2RTCMSE | null>(null); const mseRef = useRef<Go2RTCMSE | null>(null);
const webrtcRef = useRef<Go2RTCWebRTC | null>(null);
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();
const reconnectDelay = useRef(RECONNECT_DELAY);
const mountedRef = useRef(true);
const stallDetector = useRef<ReturnType<typeof setInterval>>();
const lastPlayTime = useRef(0);
const stallCount = useRef(0);
const [isConnecting, setIsConnecting] = useState(true); const [isConnecting, setIsConnecting] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [retryCount, setRetryCount] = useState(0); const [retryCount, setRetryCount] = useState(0);
@@ -27,64 +38,164 @@ export function useStream({ streamName, go2rtcUrl, delayMs = 0, enabled = true }
setRetryCount((c) => c + 1); setRetryCount((c) => c + 1);
}, []); }, []);
const scheduleReconnect = useCallback(() => {
if (!mountedRef.current) return;
clearTimeout(reconnectTimer.current);
reconnectTimer.current = setTimeout(() => {
if (mountedRef.current) {
setRetryCount((c) => c + 1);
}
}, reconnectDelay.current);
reconnectDelay.current = Math.min(reconnectDelay.current * 1.5, MAX_RECONNECT_DELAY);
}, []);
useEffect(() => { useEffect(() => {
if (!enabled || !streamName || !go2rtcUrl) return; if (!enabled || !streamName) return;
let mounted = true; mountedRef.current = true;
let timer: ReturnType<typeof setTimeout>; reconnectDelay.current = RECONNECT_DELAY;
stallCount.current = 0;
lastPlayTime.current = 0;
let initTimer: ReturnType<typeof setTimeout>;
let readyCheck: ReturnType<typeof setTimeout>;
let mseTimeout: ReturnType<typeof setTimeout>;
const connectWebRTC = async () => { const cleanup = () => {
try { if (stallDetector.current) {
setIsConnecting(true); clearInterval(stallDetector.current);
setError(null); stallDetector.current = undefined;
const webrtc = new Go2RTCWebRTC(streamName, go2rtcUrl);
webrtcRef.current = webrtc;
await webrtc.connect((stream) => {
if (mounted && videoRef.current) {
videoRef.current.srcObject = stream;
setIsConnecting(false);
} }
}); mseRef.current?.disconnect();
} catch (err) { mseRef.current = null;
if (!mounted) return; webrtcRef.current?.disconnect();
console.warn(`WebRTC failed for ${streamName}, trying MSE...`); webrtcRef.current = null;
await connectMSE(); };
const onDisconnect = () => {
if (!mountedRef.current) return;
setError('Stream disconnected');
cleanup();
scheduleReconnect();
};
const startStallDetection = () => {
if (stallDetector.current) clearInterval(stallDetector.current);
stallCount.current = 0;
lastPlayTime.current = videoRef.current?.currentTime ?? 0;
stallDetector.current = setInterval(() => {
if (!mountedRef.current) return;
const v = videoRef.current;
if (!v || v.paused) return;
const currentTime = v.currentTime;
if (currentTime === lastPlayTime.current && currentTime > 0) {
stallCount.current++;
if (stallCount.current >= STALL_THRESHOLD) {
console.warn(`Stream stalled for ${streamName}, reconnecting...`);
cleanup();
setError('Stream stalled');
scheduleReconnect();
} }
} else {
stallCount.current = 0;
}
lastPlayTime.current = currentTime;
}, STALL_CHECK_INTERVAL);
}; };
const connectMSE = async () => { const connectMSE = async () => {
try { try {
if (!mounted || !videoRef.current) return; setIsConnecting(true);
setError(null);
cleanup();
const mse = new Go2RTCMSE(streamName, go2rtcUrl); const mse = new Go2RTCMSE(streamName);
mseRef.current = mse; mseRef.current = mse;
await mse.connect(videoRef.current);
if (mounted) setIsConnecting(false); if (!videoRef.current) return;
await mse.connect(videoRef.current, onDisconnect);
// Poll for video readiness with timeout fallback to WebRTC
const checkReady = () => {
if (!mountedRef.current) return;
const v = videoRef.current;
if (v && v.readyState >= 2) {
clearTimeout(mseTimeout);
setIsConnecting(false);
setError(null);
reconnectDelay.current = RECONNECT_DELAY;
startStallDetection();
} else {
readyCheck = setTimeout(checkReady, 300);
}
};
readyCheck = setTimeout(checkReady, 300);
// Timeout: if MSE doesn't produce video, fall back to WebRTC
mseTimeout = setTimeout(() => {
if (!mountedRef.current) return;
const v = videoRef.current;
if (!v || v.readyState < 2) {
console.warn(`MSE timeout for ${streamName}, trying WebRTC...`);
clearTimeout(readyCheck);
cleanup();
connectWebRTC();
}
}, MSE_READY_TIMEOUT);
} catch (err) { } catch (err) {
if (mounted) { if (!mountedRef.current) return;
console.warn(`MSE failed for ${streamName}, trying WebRTC...`, err);
connectWebRTC();
}
};
const connectWebRTC = async () => {
try {
if (!mountedRef.current) return;
setIsConnecting(true);
setError(null);
const webrtc = new Go2RTCWebRTC(streamName);
webrtcRef.current = webrtc;
await webrtc.connect(
(stream) => {
if (mountedRef.current && videoRef.current) {
videoRef.current.srcObject = stream;
setIsConnecting(false);
setError(null);
reconnectDelay.current = RECONNECT_DELAY;
startStallDetection();
}
},
onDisconnect,
);
} catch (err) {
if (mountedRef.current) {
setError(err instanceof Error ? err.message : 'Connection failed'); setError(err instanceof Error ? err.message : 'Connection failed');
setIsConnecting(false); setIsConnecting(false);
scheduleReconnect();
} }
} }
}; };
if (delayMs > 0) { if (delayMs > 0) {
timer = setTimeout(connectWebRTC, delayMs); initTimer = setTimeout(connectMSE, delayMs);
} else { } else {
connectWebRTC(); connectMSE();
} }
return () => { return () => {
mounted = false; mountedRef.current = false;
if (timer) clearTimeout(timer); clearTimeout(initTimer);
webrtcRef.current?.disconnect(); clearTimeout(readyCheck);
webrtcRef.current = null; clearTimeout(mseTimeout);
mseRef.current?.disconnect(); clearTimeout(reconnectTimer.current);
mseRef.current = null; cleanup();
}; };
}, [streamName, go2rtcUrl, delayMs, enabled, retryCount]); }, [streamName, delayMs, enabled, retryCount, scheduleReconnect]);
return { videoRef, isConnecting, error, retry }; return { videoRef, isConnecting, error, retry };
} }

View File

@@ -1,10 +1,11 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import { ErrorBoundary } from './components/ErrorBoundary';
import './index.css'; import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <ErrorBoundary>
<App /> <App />
</React.StrictMode>, </ErrorBoundary>,
); );

View File

@@ -1,31 +1,42 @@
// All requests go through backend proxy to avoid CORS issues
export class Go2RTCWebRTC { export class Go2RTCWebRTC {
private pc: RTCPeerConnection | null = null; private pc: RTCPeerConnection | null = null;
private mediaStream: MediaStream | null = null; private mediaStream: MediaStream | null = null;
private streamName: string; private streamName: string;
private go2rtcUrl: string;
private onTrackCb: ((stream: MediaStream) => void) | null = null; private onTrackCb: ((stream: MediaStream) => void) | null = null;
private onDisconnectCb: (() => void) | null = null;
private disposed = false;
constructor(streamName: string, go2rtcUrl: string) { constructor(streamName: string, _go2rtcUrl?: string) {
this.streamName = streamName; this.streamName = streamName;
this.go2rtcUrl = go2rtcUrl;
} }
async connect(onTrack: (stream: MediaStream) => void): Promise<void> { async connect(
onTrack: (stream: MediaStream) => void,
onDisconnect?: () => void,
): Promise<void> {
this.onTrackCb = onTrack; this.onTrackCb = onTrack;
this.onDisconnectCb = onDisconnect ?? null;
this.cleanup();
this.pc = new RTCPeerConnection({ this.pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
}); });
this.pc.ontrack = (event) => { this.pc.ontrack = (event) => {
if (this.disposed) return;
if (event.streams?.[0]) { if (event.streams?.[0]) {
this.mediaStream = event.streams[0]; this.mediaStream = event.streams[0];
this.onTrackCb?.(this.mediaStream); this.onTrackCb?.(this.mediaStream);
} }
}; };
this.pc.onicecandidate = () => { this.pc.onconnectionstatechange = () => {
// go2rtc handles ICE internally via the initial SDP exchange const state = this.pc?.connectionState;
if (!this.disposed && (state === 'disconnected' || state === 'failed')) {
this.onDisconnectCb?.();
}
}; };
this.pc.addTransceiver('video', { direction: 'recvonly' }); this.pc.addTransceiver('video', { direction: 'recvonly' });
@@ -34,11 +45,15 @@ export class Go2RTCWebRTC {
const offer = await this.pc.createOffer(); const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer); await this.pc.setLocalDescription(offer);
const url = `${this.go2rtcUrl}/api/webrtc?src=${encodeURIComponent(this.streamName)}`; // Wait for ICE gathering to complete (or timeout after 2s)
const localDesc = await this.waitForIceGathering(2000);
// Use backend proxy instead of direct go2rtc
const url = `/api/go2rtc/webrtc?src=${encodeURIComponent(this.streamName)}`;
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/sdp' }, headers: { 'Content-Type': 'application/sdp' },
body: offer.sdp, body: localDesc.sdp,
}); });
if (!res.ok) { if (!res.ok) {
@@ -49,12 +64,50 @@ export class Go2RTCWebRTC {
await this.pc.setRemoteDescription({ type: 'answer', sdp: answerSdp }); await this.pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
} }
disconnect(): void { private waitForIceGathering(timeoutMs: number): Promise<RTCSessionDescription> {
return new Promise((resolve) => {
if (!this.pc) {
resolve(this.pc!.localDescription!);
return;
}
if (this.pc.iceGatheringState === 'complete') {
resolve(this.pc.localDescription!);
return;
}
const timeout = setTimeout(() => {
if (this.pc?.localDescription) {
resolve(this.pc.localDescription);
}
}, timeoutMs);
this.pc.onicegatheringstatechange = () => {
if (this.pc?.iceGatheringState === 'complete') {
clearTimeout(timeout);
resolve(this.pc.localDescription!);
}
};
});
}
private cleanup(): void {
this.mediaStream?.getTracks().forEach((t) => t.stop()); this.mediaStream?.getTracks().forEach((t) => t.stop());
this.mediaStream = null; this.mediaStream = null;
this.pc?.close(); if (this.pc) {
this.pc.ontrack = null;
this.pc.onicegatheringstatechange = null;
this.pc.onconnectionstatechange = null;
this.pc.close();
this.pc = null; this.pc = null;
}
}
disconnect(): void {
this.disposed = true;
this.cleanup();
this.onTrackCb = null; this.onTrackCb = null;
this.onDisconnectCb = null;
} }
isConnected(): boolean { isConnected(): boolean {
@@ -62,21 +115,29 @@ export class Go2RTCWebRTC {
} }
} }
const MAX_BUFFER_SECONDS = 30;
const BUFFER_TRIM_SECONDS = 15;
export class Go2RTCMSE { export class Go2RTCMSE {
private mediaSource: MediaSource | null = null; private mediaSource: MediaSource | null = null;
private sourceBuffer: SourceBuffer | null = null; private sourceBuffer: SourceBuffer | null = null;
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private streamName: string; private streamName: string;
private go2rtcUrl: string;
private videoElement: HTMLVideoElement | null = null; private videoElement: HTMLVideoElement | null = null;
private queue: ArrayBuffer[] = []; private queue: ArrayBuffer[] = [];
private onDisconnectCb: (() => void) | null = null;
private disposed = false;
private bufferTrimInterval: ReturnType<typeof setInterval> | null = null;
constructor(streamName: string, go2rtcUrl: string) { constructor(streamName: string, _go2rtcUrl?: string) {
this.streamName = streamName; this.streamName = streamName;
this.go2rtcUrl = go2rtcUrl;
} }
async connect(videoElement: HTMLVideoElement): Promise<void> { async connect(
videoElement: HTMLVideoElement,
onDisconnect?: () => void,
): Promise<void> {
this.onDisconnectCb = onDisconnect ?? null;
this.videoElement = videoElement; this.videoElement = videoElement;
this.mediaSource = new MediaSource(); this.mediaSource = new MediaSource();
videoElement.src = URL.createObjectURL(this.mediaSource); videoElement.src = URL.createObjectURL(this.mediaSource);
@@ -85,10 +146,27 @@ export class Go2RTCMSE {
this.mediaSource!.addEventListener('sourceopen', () => resolve(), { once: true }); this.mediaSource!.addEventListener('sourceopen', () => resolve(), { once: true });
}); });
const wsUrl = `${this.go2rtcUrl.replace('http', 'ws')}/api/ws?src=${encodeURIComponent(this.streamName)}`; // Use backend proxy WebSocket
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/api/go2rtc/ws?src=${encodeURIComponent(this.streamName)}`;
this.ws = new WebSocket(wsUrl); this.ws = new WebSocket(wsUrl);
this.ws.binaryType = 'arraybuffer'; this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
// go2rtc requires this init message to start MSE streaming
this.ws?.send(JSON.stringify({ type: 'mse' }));
};
this.ws.onclose = () => {
if (!this.disposed) {
this.onDisconnectCb?.();
}
};
this.ws.onerror = () => {
// onclose will fire after onerror
};
this.ws.onmessage = (event) => { this.ws.onmessage = (event) => {
if (typeof event.data === 'string') { if (typeof event.data === 'string') {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
@@ -99,10 +177,13 @@ export class Go2RTCMSE {
if (this.sourceBuffer.updating) { if (this.sourceBuffer.updating) {
this.queue.push(event.data); this.queue.push(event.data);
} else { } else {
this.sourceBuffer.appendBuffer(event.data); this.safeAppend(event.data);
} }
} }
}; };
// Periodically trim buffer to prevent unbounded growth
this.bufferTrimInterval = setInterval(() => this.trimBuffer(), 10000);
} }
private initSourceBuffer(codec: string): void { private initSourceBuffer(codec: string): void {
@@ -112,7 +193,13 @@ export class Go2RTCMSE {
this.sourceBuffer.mode = 'segments'; this.sourceBuffer.mode = 'segments';
this.sourceBuffer.addEventListener('updateend', () => { this.sourceBuffer.addEventListener('updateend', () => {
if (this.queue.length > 0 && this.sourceBuffer && !this.sourceBuffer.updating) { if (this.queue.length > 0 && this.sourceBuffer && !this.sourceBuffer.updating) {
this.sourceBuffer.appendBuffer(this.queue.shift()!); this.safeAppend(this.queue.shift()!);
}
});
this.sourceBuffer.addEventListener('error', () => {
console.error(`SourceBuffer error for ${this.streamName}`);
if (!this.disposed) {
this.onDisconnectCb?.();
} }
}); });
} catch (e) { } catch (e) {
@@ -120,7 +207,47 @@ export class Go2RTCMSE {
} }
} }
private safeAppend(data: ArrayBuffer): void {
try {
this.sourceBuffer!.appendBuffer(data);
} catch (e) {
if (e instanceof DOMException && e.name === 'QuotaExceededError') {
console.warn(`Buffer quota exceeded for ${this.streamName}, trimming...`);
this.queue.unshift(data);
this.trimBuffer();
} else {
console.error(`appendBuffer error for ${this.streamName}:`, e);
if (!this.disposed) {
this.onDisconnectCb?.();
}
}
}
}
private trimBuffer(): void {
if (!this.sourceBuffer || this.sourceBuffer.updating || !this.videoElement) return;
const buffered = this.sourceBuffer.buffered;
if (buffered.length === 0) return;
const currentTime = this.videoElement.currentTime;
const bufferStart = buffered.start(0);
const bufferedAmount = currentTime - bufferStart;
if (bufferedAmount > MAX_BUFFER_SECONDS) {
try {
this.sourceBuffer.remove(bufferStart, currentTime - BUFFER_TRIM_SECONDS);
} catch {
// remove() can throw if already updating
}
}
}
disconnect(): void { disconnect(): void {
this.disposed = true;
if (this.bufferTrimInterval) {
clearInterval(this.bufferTrimInterval);
this.bufferTrimInterval = null;
}
this.ws?.close(); this.ws?.close();
this.ws = null; this.ws = null;
if (this.mediaSource?.readyState === 'open') { if (this.mediaSource?.readyState === 'open') {
@@ -133,5 +260,6 @@ export class Go2RTCMSE {
this.sourceBuffer = null; this.sourceBuffer = null;
this.mediaSource = null; this.mediaSource = null;
this.queue = []; this.queue = [];
this.onDisconnectCb = null;
} }
} }

View File

@@ -6,50 +6,40 @@ interface ConfigState {
config: AppConfig | null; config: AppConfig | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
cameras: CameraConfig[];
loadConfig: () => Promise<void>; loadConfig: () => Promise<void>;
saveConfig: (config: AppConfig) => Promise<void>; saveConfig: (config: AppConfig) => Promise<void>;
enabledCameras: () => CameraConfig[];
} }
const defaultConfig: AppConfig = { function deriveEnabledCameras(config: AppConfig | null): CameraConfig[] {
title: 'Camera Viewer', if (!config) return [];
go2rtc: { url: 'http://192.168.1.241:1985' }, return config.cameras
frigate: { url: 'http://192.168.1.241:5000' }, .filter((c) => c.enabled)
mqtt: { host: '', port: 1883, topic_prefix: 'frigate', username: '', password: '' }, .sort((a, b) => a.order - b.order);
cameras: [], }
alerts: { enabled: false, auto_dismiss_seconds: 30, suppression_seconds: 60, cameras: [], detection_types: ['person'] },
grid: { columns: null, aspect_ratio: '16:9', gap: 4 },
};
export const useConfigStore = create<ConfigState>((set, get) => ({ export const useConfigStore = create<ConfigState>((set) => ({
config: null, config: null,
loading: true, loading: true,
error: null, error: null,
cameras: [],
loadConfig: async () => { loadConfig: async () => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const config = await fetchConfig(); const config = await fetchConfig();
set({ config, loading: false }); set({ config, loading: false, cameras: deriveEnabledCameras(config) });
} catch (e) { } catch (e) {
set({ config: defaultConfig, loading: false, error: String(e) }); set({ loading: false, error: String(e), cameras: [] });
} }
}, },
saveConfig: async (config: AppConfig) => { saveConfig: async (config: AppConfig) => {
try { try {
await apiSaveConfig(config); await apiSaveConfig(config);
set({ config }); set({ config, cameras: deriveEnabledCameras(config) });
} catch (e) { } catch (e) {
set({ error: String(e) }); set({ error: String(e) });
} }
}, },
enabledCameras: () => {
const config = get().config;
if (!config) return [];
return config.cameras
.filter((c) => c.enabled)
.sort((a, b) => a.order - b.order);
},
})); }));

View File

@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/alerts/AlertPopup.tsx","./src/components/grid/CameraGrid.tsx","./src/components/grid/CameraGridCell.tsx","./src/components/layout/AppShell.tsx","./src/components/layout/Header.tsx","./src/components/player/CameraPlayer.tsx","./src/components/player/FullscreenView.tsx","./src/components/settings/AlertSettings.tsx","./src/components/settings/CameraSettings.tsx","./src/components/settings/GeneralSettings.tsx","./src/components/settings/SettingsPage.tsx","./src/hooks/useAlerts.ts","./src/hooks/useStream.ts","./src/services/alerts.ts","./src/services/api.ts","./src/services/go2rtc.ts","./src/stores/alertStore.ts","./src/stores/configStore.ts","./src/stores/uiStore.ts","./src/types/config.ts"],"version":"5.6.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/ErrorBoundary.tsx","./src/components/alerts/AlertPopup.tsx","./src/components/grid/CameraGrid.tsx","./src/components/grid/CameraGridCell.tsx","./src/components/layout/AppShell.tsx","./src/components/layout/Header.tsx","./src/components/player/CameraPlayer.tsx","./src/components/player/FullscreenView.tsx","./src/components/settings/AlertSettings.tsx","./src/components/settings/CameraSettings.tsx","./src/components/settings/GeneralSettings.tsx","./src/components/settings/SettingsPage.tsx","./src/hooks/useAlerts.ts","./src/hooks/useStream.ts","./src/services/alerts.ts","./src/services/api.ts","./src/services/go2rtc.ts","./src/stores/alertStore.ts","./src/stores/configStore.ts","./src/stores/uiStore.ts","./src/types/config.ts"],"version":"5.6.3"}

20
monitoring/config.yaml Normal file
View File

@@ -0,0 +1,20 @@
go2rtc_url: "http://192.168.1.241:1985"
frigate_url: "http://192.168.1.241:5000"
poll_interval: 15
request_timeout: 5
metrics_port: 9199
cameras:
- FPE
- Porch_Downstairs
- Front_Porch
- Driveway_door
- Street_side
- Backyard
- House_side
- Driveway
- BackDoor
- Parlor
- Livingroom
- WyzePanV3

184
monitoring/exporter.py Normal file
View File

@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Prometheus exporter for go2rtc and Frigate camera stream health."""
import logging
import signal
import sys
import time
from pathlib import Path
import requests
import yaml
from prometheus_client import Gauge, start_http_server
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("go2rtc-exporter")
# --- Metrics ---
go2rtc_stream_up = Gauge(
"go2rtc_stream_up",
"Whether a go2rtc stream has active producers (1=up, 0=down)",
["camera"],
)
go2rtc_stream_bytes_per_second = Gauge(
"go2rtc_stream_bytes_per_second",
"Bytes per second received by go2rtc producer",
["camera"],
)
go2rtc_stream_consumers = Gauge(
"go2rtc_stream_consumers",
"Number of consumers connected to the stream",
["camera"],
)
frigate_camera_fps = Gauge(
"frigate_camera_fps",
"Camera FPS reported by Frigate",
["camera"],
)
go2rtc_up = Gauge(
"go2rtc_up",
"Whether go2rtc API is reachable (1=up, 0=down)",
)
frigate_up = Gauge(
"frigate_up",
"Whether Frigate API is reachable (1=up, 0=down)",
)
# --- State ---
prev_bytes: dict[str, int] = {}
prev_time: dict[str, float] = {}
def load_config(path: str) -> dict:
with open(path) as f:
return yaml.safe_load(f)
def poll_go2rtc(config: dict) -> None:
url = f"{config['go2rtc_url']}/api/streams"
timeout = config.get("request_timeout", 5)
cameras = config["cameras"]
try:
resp = requests.get(url, timeout=timeout)
resp.raise_for_status()
streams = resp.json()
go2rtc_up.set(1)
except Exception as e:
log.warning("go2rtc unreachable: %s", e)
go2rtc_up.set(0)
for cam in cameras:
go2rtc_stream_up.labels(camera=cam).set(0)
go2rtc_stream_bytes_per_second.labels(camera=cam).set(0)
go2rtc_stream_consumers.labels(camera=cam).set(0)
return
now = time.monotonic()
for cam in cameras:
stream = streams.get(cam)
if stream is None:
go2rtc_stream_up.labels(camera=cam).set(0)
go2rtc_stream_bytes_per_second.labels(camera=cam).set(0)
go2rtc_stream_consumers.labels(camera=cam).set(0)
continue
producers = stream.get("producers") or []
consumers = stream.get("consumers") or []
# A stream is "up" if it has at least one producer
has_producer = len(producers) > 0
go2rtc_stream_up.labels(camera=cam).set(1 if has_producer else 0)
go2rtc_stream_consumers.labels(camera=cam).set(len(consumers))
# Calculate bytes/sec from delta
total_bytes = sum(p.get("bytes_recv", 0) for p in producers)
if cam in prev_bytes and cam in prev_time:
dt = now - prev_time[cam]
if dt > 0:
dbytes = total_bytes - prev_bytes[cam]
# Handle counter reset (go2rtc restart)
if dbytes < 0:
dbytes = total_bytes
bps = dbytes / dt
go2rtc_stream_bytes_per_second.labels(camera=cam).set(round(bps, 1))
else:
go2rtc_stream_bytes_per_second.labels(camera=cam).set(0)
else:
# First poll — no delta yet
go2rtc_stream_bytes_per_second.labels(camera=cam).set(0)
prev_bytes[cam] = total_bytes
prev_time[cam] = now
def poll_frigate(config: dict) -> None:
url = f"{config['frigate_url']}/api/stats"
timeout = config.get("request_timeout", 5)
cameras = config["cameras"]
try:
resp = requests.get(url, timeout=timeout)
resp.raise_for_status()
stats = resp.json()
frigate_up.set(1)
except Exception as e:
log.warning("Frigate unreachable: %s", e)
frigate_up.set(0)
for cam in cameras:
frigate_camera_fps.labels(camera=cam).set(0)
return
cam_stats = stats.get("cameras", {})
for cam in cameras:
cs = cam_stats.get(cam)
if cs and isinstance(cs, dict):
frigate_camera_fps.labels(camera=cam).set(cs.get("camera_fps", 0))
else:
frigate_camera_fps.labels(camera=cam).set(0)
def main() -> None:
config_path = Path(__file__).parent / "config.yaml"
if len(sys.argv) > 1:
config_path = Path(sys.argv[1])
config = load_config(str(config_path))
port = config.get("metrics_port", 9199)
interval = config.get("poll_interval", 15)
log.info("Starting go2rtc exporter on port %d (poll every %ds)", port, interval)
log.info("Monitoring cameras: %s", ", ".join(config["cameras"]))
start_http_server(port)
shutdown = False
def handle_signal(signum, frame):
nonlocal shutdown
log.info("Received signal %d, shutting down", signum)
shutdown = True
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)
while not shutdown:
try:
poll_go2rtc(config)
poll_frigate(config)
except Exception:
log.exception("Unexpected error during poll")
time.sleep(interval)
log.info("Exporter stopped")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,3 @@
prometheus-client>=0.20.0
requests>=2.31.0
pyyaml>=6.0

View File

@@ -4,11 +4,10 @@ After=network.target
[Service] [Service]
Type=simple Type=simple
WorkingDirectory=/opt/camera-viewer WorkingDirectory=/opt/camera-viewer/backend
ExecStart=/opt/camera-viewer/venv/bin/uvicorn backend.main:app --host 0.0.0.0 --port 8080 ExecStart=/opt/camera-viewer/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8080
Restart=always Restart=always
RestartSec=5 RestartSec=5
Environment=PYTHONPATH=/opt/camera-viewer
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target