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
This commit is contained in:
root
2026-02-26 15:55:44 -06:00
parent b630ba0337
commit 23ca4ee742
3 changed files with 114 additions and 28 deletions

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

@@ -1,6 +1,9 @@
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;
interface UseStreamOptions { interface UseStreamOptions {
streamName: string; streamName: string;
delayMs?: number; delayMs?: number;
@@ -18,6 +21,9 @@ export function useStream({ streamName, delayMs = 0, enabled = true }: UseStream
const videoRef = useRef<HTMLVideoElement>(null!); const videoRef = useRef<HTMLVideoElement>(null!);
const mseRef = useRef<Go2RTCMSE | null>(null); const mseRef = useRef<Go2RTCMSE | null>(null);
const webrtcRef = useRef<Go2RTCWebRTC | null>(null); const webrtcRef = useRef<Go2RTCWebRTC | null>(null);
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();
const reconnectDelay = useRef(RECONNECT_DELAY);
const mountedRef = useRef(true);
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);
@@ -26,30 +32,59 @@ export function useStream({ streamName, delayMs = 0, enabled = true }: UseStream
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) return; if (!enabled || !streamName) return;
let mounted = true; mountedRef.current = true;
let timer: ReturnType<typeof setTimeout>; reconnectDelay.current = RECONNECT_DELAY;
let initTimer: ReturnType<typeof setTimeout>;
let readyCheck: ReturnType<typeof setTimeout>; let readyCheck: ReturnType<typeof setTimeout>;
const cleanup = () => {
mseRef.current?.disconnect();
mseRef.current = null;
webrtcRef.current?.disconnect();
webrtcRef.current = null;
};
const onDisconnect = () => {
if (!mountedRef.current) return;
setError('Stream disconnected');
cleanup();
scheduleReconnect();
};
const connectMSE = async () => { const connectMSE = async () => {
try { try {
setIsConnecting(true); setIsConnecting(true);
setError(null); setError(null);
cleanup();
const mse = new Go2RTCMSE(streamName); const mse = new Go2RTCMSE(streamName);
mseRef.current = mse; mseRef.current = mse;
if (!videoRef.current) return; if (!videoRef.current) return;
await mse.connect(videoRef.current); await mse.connect(videoRef.current, onDisconnect);
// Poll for video readiness // Poll for video readiness
const checkReady = () => { const checkReady = () => {
if (!mounted) return; if (!mountedRef.current) return;
const v = videoRef.current; const v = videoRef.current;
if (v && v.readyState >= 2) { if (v && v.readyState >= 2) {
setIsConnecting(false); setIsConnecting(false);
setError(null);
reconnectDelay.current = RECONNECT_DELAY;
} else { } else {
readyCheck = setTimeout(checkReady, 300); readyCheck = setTimeout(checkReady, 300);
} }
@@ -57,7 +92,7 @@ export function useStream({ streamName, delayMs = 0, enabled = true }: UseStream
readyCheck = setTimeout(checkReady, 300); readyCheck = setTimeout(checkReady, 300);
} catch (err) { } catch (err) {
if (!mounted) return; if (!mountedRef.current) return;
console.warn(`MSE failed for ${streamName}, trying WebRTC...`, err); console.warn(`MSE failed for ${streamName}, trying WebRTC...`, err);
connectWebRTC(); connectWebRTC();
} }
@@ -65,43 +100,47 @@ export function useStream({ streamName, delayMs = 0, enabled = true }: UseStream
const connectWebRTC = async () => { const connectWebRTC = async () => {
try { try {
if (!mounted) return; if (!mountedRef.current) return;
setIsConnecting(true); setIsConnecting(true);
setError(null); setError(null);
const webrtc = new Go2RTCWebRTC(streamName); const webrtc = new Go2RTCWebRTC(streamName);
webrtcRef.current = webrtc; webrtcRef.current = webrtc;
await webrtc.connect((stream) => { await webrtc.connect(
if (mounted && videoRef.current) { (stream) => {
videoRef.current.srcObject = stream; if (mountedRef.current && videoRef.current) {
setIsConnecting(false); videoRef.current.srcObject = stream;
} setIsConnecting(false);
}); setError(null);
reconnectDelay.current = RECONNECT_DELAY;
}
},
onDisconnect,
);
} catch (err) { } catch (err) {
if (mounted) { 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(connectMSE, delayMs); initTimer = setTimeout(connectMSE, delayMs);
} else { } else {
connectMSE(); connectMSE();
} }
return () => { return () => {
mounted = false; mountedRef.current = false;
if (timer) clearTimeout(timer); clearTimeout(initTimer);
if (readyCheck) clearTimeout(readyCheck); clearTimeout(readyCheck);
mseRef.current?.disconnect(); clearTimeout(reconnectTimer.current);
mseRef.current = null; cleanup();
webrtcRef.current?.disconnect();
webrtcRef.current = null;
}; };
}, [streamName, delayMs, enabled, retryCount]); }, [streamName, delayMs, enabled, retryCount, scheduleReconnect]);
return { videoRef, isConnecting, error, retry }; return { videoRef, isConnecting, error, retry };
} }

View File

@@ -5,19 +5,27 @@ export class Go2RTCWebRTC {
private mediaStream: MediaStream | null = null; private mediaStream: MediaStream | null = null;
private streamName: string; private streamName: 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;
} }
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);
@@ -26,6 +34,13 @@ export class Go2RTCWebRTC {
this.pc.onicecandidate = () => {}; this.pc.onicecandidate = () => {};
this.pc.onconnectionstatechange = () => {
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' });
this.pc.addTransceiver('audio', { direction: 'recvonly' }); this.pc.addTransceiver('audio', { direction: 'recvonly' });
@@ -48,12 +63,23 @@ export class Go2RTCWebRTC {
await this.pc.setRemoteDescription({ type: 'answer', sdp: answerSdp }); await this.pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
} }
disconnect(): void { 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 = null; this.pc.ontrack = null;
this.pc.onicecandidate = null;
this.pc.onconnectionstatechange = null;
this.pc.close();
this.pc = null;
}
}
disconnect(): void {
this.disposed = true;
this.cleanup();
this.onTrackCb = null; this.onTrackCb = null;
this.onDisconnectCb = null;
} }
isConnected(): boolean { isConnected(): boolean {
@@ -68,12 +94,18 @@ export class Go2RTCMSE {
private streamName: string; private streamName: 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;
constructor(streamName: string, _go2rtcUrl?: string) { constructor(streamName: string, _go2rtcUrl?: string) {
this.streamName = streamName; this.streamName = streamName;
} }
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);
@@ -93,6 +125,16 @@ export class Go2RTCMSE {
this.ws?.send(JSON.stringify({ type: 'mse' })); 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);
@@ -125,6 +167,7 @@ export class Go2RTCMSE {
} }
disconnect(): void { disconnect(): void {
this.disposed = true;
this.ws?.close(); this.ws?.close();
this.ws = null; this.ws = null;
if (this.mediaSource?.readyState === 'open') { if (this.mediaSource?.readyState === 'open') {
@@ -137,5 +180,6 @@ export class Go2RTCMSE {
this.sourceBuffer = null; this.sourceBuffer = null;
this.mediaSource = null; this.mediaSource = null;
this.queue = []; this.queue = [];
this.onDisconnectCb = null;
} }
} }