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
auto_dismiss_seconds: 30
suppression_seconds: 60
cameras: []
cameras:
- "Front_Porch"
- "Porch_Downstairs"
- "Driveway_door"
detection_types:
- "person"

View File

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

View File

@@ -5,19 +5,27 @@ export class Go2RTCWebRTC {
private mediaStream: MediaStream | null = null;
private streamName: string;
private onTrackCb: ((stream: MediaStream) => void) | null = null;
private onDisconnectCb: (() => void) | null = null;
private disposed = false;
constructor(streamName: string, _go2rtcUrl?: string) {
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.onDisconnectCb = onDisconnect ?? null;
this.cleanup();
this.pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
this.pc.ontrack = (event) => {
if (this.disposed) return;
if (event.streams?.[0]) {
this.mediaStream = event.streams[0];
this.onTrackCb?.(this.mediaStream);
@@ -26,6 +34,13 @@ export class Go2RTCWebRTC {
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('audio', { direction: 'recvonly' });
@@ -48,12 +63,23 @@ export class Go2RTCWebRTC {
await this.pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
}
disconnect(): void {
private cleanup(): void {
this.mediaStream?.getTracks().forEach((t) => t.stop());
this.mediaStream = null;
this.pc?.close();
if (this.pc) {
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.onDisconnectCb = null;
}
isConnected(): boolean {
@@ -68,12 +94,18 @@ export class Go2RTCMSE {
private streamName: string;
private videoElement: HTMLVideoElement | null = null;
private queue: ArrayBuffer[] = [];
private onDisconnectCb: (() => void) | null = null;
private disposed = false;
constructor(streamName: string, _go2rtcUrl?: string) {
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.mediaSource = new MediaSource();
videoElement.src = URL.createObjectURL(this.mediaSource);
@@ -93,6 +125,16 @@ export class Go2RTCMSE {
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) => {
if (typeof event.data === 'string') {
const msg = JSON.parse(event.data);
@@ -125,6 +167,7 @@ export class Go2RTCMSE {
}
disconnect(): void {
this.disposed = true;
this.ws?.close();
this.ws = null;
if (this.mediaSource?.readyState === 'open') {
@@ -137,5 +180,6 @@ export class Go2RTCMSE {
this.sourceBuffer = null;
this.mediaSource = null;
this.queue = [];
this.onDisconnectCb = null;
}
}