Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
898af6022b | ||
|
|
c39ec4e954 | ||
|
|
f26acc409a | ||
|
|
23ca4ee742 | ||
|
|
b630ba0337 | ||
|
|
ba2824ec56 | ||
|
|
da5637fbdb |
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
90
backend/routes/go2rtc_proxy.py
Normal file
90
backend/routes/go2rtc_proxy.py
Normal 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)
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
32
frontend/src/components/ErrorBoundary.tsx
Normal file
32
frontend/src/components/ErrorBoundary.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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
20
monitoring/config.yaml
Normal 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
184
monitoring/exporter.py
Normal 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()
|
||||||
3
monitoring/requirements.txt
Normal file
3
monitoring/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
prometheus-client>=0.20.0
|
||||||
|
requests>=2.31.0
|
||||||
|
pyyaml>=6.0
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user