From 58ebd3e2395bc72e20aa4f2187ff7d9110c20bce Mon Sep 17 00:00:00 2001 From: root Date: Thu, 26 Feb 2026 15:33:25 -0600 Subject: [PATCH] Fix camera feed freezing and person detection alerts - Add WebRTC auto-reconnect with exponential backoff when streams disconnect or fail, preventing permanent freezes in grid view - Replace hard-coded Front Porch person alert with generic system that monitors all configured personDetectionEntities - Map Frigate person_occupancy entities to cameras dynamically - Show correct camera name and feed in alert overlay - Bump config version to refresh detection entity defaults --- src/App.tsx | 67 ++++++++++-------- src/components/cameras/CameraFeed.tsx | 99 ++++++++++++++++++++------- src/services/go2rtc/webrtc.ts | 47 +++++++++---- src/stores/settingsStore.ts | 2 +- 4 files changed, 151 insertions(+), 64 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 11d7f73..8af322e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,17 +19,26 @@ import { useUIStore, useCameraOverlay } from '@/stores/uiStore'; import { useSettingsStore } from '@/stores/settingsStore'; import { env } from '@/config/environment'; -// Front porch alert overlay - shows for 30 seconds when person detected -function FrontPorchAlert({ onClose }: { onClose: () => void }) { +// Map a Frigate person_occupancy entity to its camera name +// e.g. 'binary_sensor.fpe_person_occupancy' -> 'fpe' -> match camera by frigateCamera +function entityToCameraName(entityId: string): string | null { + const match = entityId.match(/^binary_sensor\.(.+)_person_occupancy$/); + return match ? match[1] : null; +} + +// Person detection alert overlay - shows for 30 seconds when person detected on any configured camera +function PersonAlert({ cameraName, onClose }: { cameraName: string; onClose: () => void }) { const cameras = useSettingsStore((state) => state.config.cameras); - const frontPorchCamera = cameras.find((c) => c.name === 'Front_Porch'); + const camera = cameras.find( + (c) => c.frigateCamera?.toLowerCase() === cameraName || c.name.toLowerCase() === cameraName, + ); useEffect(() => { - const timer = setTimeout(onClose, 30000); // 30 seconds + const timer = setTimeout(onClose, 30000); return () => clearTimeout(timer); }, [onClose]); - if (!frontPorchCamera) return null; + if (!camera) return null; return (
@@ -37,7 +46,7 @@ function FrontPorchAlert({ onClose }: { onClose: () => void }) {

- Person Detected - Front Porch + Person Detected - {camera.displayName}

@@ -174,9 +183,10 @@ export default function App() { const mediaOverlayOpen = useUIStore((state) => state.mediaOverlayOpen); const { isOpen: cameraOverlayOpen } = useCameraOverlay(); - // Front porch alert state - const [showFrontPorchAlert, setShowFrontPorchAlert] = useState(false); - const frontPorchAlertShownRef = useRef(false); + // Person detection alert state + const personDetectionEntities = useSettingsStore((state) => state.config.personDetectionEntities); + const [alertCamera, setAlertCamera] = useState(null); + const alertShownForRef = useRef>(new Set()); // Motion detection now runs in the Electron main process (MotionDetector.ts) // This prevents browser throttling when the screensaver is active @@ -230,29 +240,32 @@ export default function App() { initConfig(); }, [accessToken, connect]); - // Listen for Front Porch person detection - show full screen overlay for 30 seconds + // Listen for person detection on all configured entities useEffect(() => { if (!isConnected) return; - const frontPorchEntity = entities['binary_sensor.front_porch_person_occupancy']; - const isPersonDetected = frontPorchEntity?.state === 'on'; + for (const entityId of personDetectionEntities) { + const entity = entities[entityId]; + const isDetected = entity?.state === 'on'; + const cameraName = entityToCameraName(entityId); - if (isPersonDetected && !frontPorchAlertShownRef.current) { - // Person just detected - show alert - frontPorchAlertShownRef.current = true; - setShowFrontPorchAlert(true); - // Also wake the screen - if (window.electronAPI?.screen?.wake) { - window.electronAPI.screen.wake(); + if (isDetected && cameraName && !alertShownForRef.current.has(entityId)) { + // Person just detected on this camera - show alert + alertShownForRef.current.add(entityId); + setAlertCamera(cameraName); + if (window.electronAPI?.screen?.wake) { + window.electronAPI.screen.wake(); + } + break; // Show one alert at a time + } else if (!isDetected) { + // Reset so next detection on this entity triggers again + alertShownForRef.current.delete(entityId); } - } else if (!isPersonDetected) { - // Reset flag when person clears so next detection triggers alert - frontPorchAlertShownRef.current = false; } - }, [isConnected, entities]); + }, [isConnected, entities, personDetectionEntities]); - const closeFrontPorchAlert = useCallback(() => { - setShowFrontPorchAlert(false); + const closePersonAlert = useCallback(() => { + setAlertCamera(null); }, []); // Set up screen idle timeout @@ -321,7 +334,7 @@ export default function App() { {mediaOverlayOpen && } {cameraOverlayOpen && } {settingsOpen && } - {showFrontPorchAlert && } + {alertCamera && } ); diff --git a/src/components/cameras/CameraFeed.tsx b/src/components/cameras/CameraFeed.tsx index 28bad23..22b6336 100644 --- a/src/components/cameras/CameraFeed.tsx +++ b/src/components/cameras/CameraFeed.tsx @@ -1,7 +1,10 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useCallback } from 'react'; import { Go2RTCWebRTC } from '@/services/go2rtc'; import { CameraConfig } from '@/config/entities'; +const RECONNECT_DELAY_MS = 3000; +const MAX_RECONNECT_DELAY_MS = 30000; + interface CameraFeedProps { camera: CameraConfig; onClick?: () => void; @@ -21,50 +24,99 @@ export function CameraFeed({ }: CameraFeedProps) { const videoRef = useRef(null); const webrtcRef = useRef(null); + const reconnectTimerRef = useRef>(); + const reconnectDelayRef = useRef(RECONNECT_DELAY_MS); + const mountedRef = useRef(true); const [isConnecting, setIsConnecting] = useState(true); const [error, setError] = useState(null); // Use substream for grid view (lower bandwidth) const streamName = useSubstream ? `${camera.go2rtcStream}_sub` : camera.go2rtcStream; - useEffect(() => { - let mounted = true; - let timeoutId: ReturnType; + const connect = useCallback(async () => { + if (!mountedRef.current) return; - const connect = async () => { - try { - setIsConnecting(true); - setError(null); + try { + setIsConnecting(true); + setError(null); - webrtcRef.current = new Go2RTCWebRTC(streamName); + // Disconnect previous instance + webrtcRef.current?.disconnect(); - await webrtcRef.current.connect((stream) => { - if (mounted && videoRef.current) { + const webrtc = new Go2RTCWebRTC(streamName); + webrtcRef.current = webrtc; + + await webrtc.connect( + // onTrack + (stream) => { + if (mountedRef.current && videoRef.current) { videoRef.current.srcObject = stream; setIsConnecting(false); + setError(null); + // Reset backoff on successful connection + reconnectDelayRef.current = RECONNECT_DELAY_MS; } - }); - } catch (err) { - if (mounted) { - setError(err instanceof Error ? err.message : 'Failed to connect'); - setIsConnecting(false); + }, + // onDisconnect - schedule reconnect + () => { + if (!mountedRef.current) return; + console.log(`WebRTC disconnected for ${streamName}, reconnecting in ${reconnectDelayRef.current}ms`); + setError('Stream disconnected'); + + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = setTimeout(() => { + if (mountedRef.current) { + connect(); + } + }, reconnectDelayRef.current); + + // Exponential backoff capped at max + reconnectDelayRef.current = Math.min( + reconnectDelayRef.current * 1.5, + MAX_RECONNECT_DELAY_MS, + ); + }, + ); + } catch (err) { + if (!mountedRef.current) return; + const msg = err instanceof Error ? err.message : 'Failed to connect'; + setError(msg); + setIsConnecting(false); + + // Retry on connection failure too + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = setTimeout(() => { + if (mountedRef.current) { + connect(); } - } - }; + }, reconnectDelayRef.current); + + reconnectDelayRef.current = Math.min( + reconnectDelayRef.current * 1.5, + MAX_RECONNECT_DELAY_MS, + ); + } + }, [streamName]); + + useEffect(() => { + mountedRef.current = true; + reconnectDelayRef.current = RECONNECT_DELAY_MS; + let initTimer: ReturnType; // Stagger connections to avoid overwhelming go2rtc if (delayMs > 0) { - timeoutId = setTimeout(connect, delayMs); + initTimer = setTimeout(connect, delayMs); } else { connect(); } return () => { - mounted = false; - if (timeoutId) clearTimeout(timeoutId); + mountedRef.current = false; + clearTimeout(initTimer); + clearTimeout(reconnectTimerRef.current); webrtcRef.current?.disconnect(); }; - }, [streamName, delayMs]); + }, [connect, delayMs]); return (
{error} + Reconnecting...
)} diff --git a/src/services/go2rtc/webrtc.ts b/src/services/go2rtc/webrtc.ts index cd4da17..62bec8e 100644 --- a/src/services/go2rtc/webrtc.ts +++ b/src/services/go2rtc/webrtc.ts @@ -10,13 +10,22 @@ export class Go2RTCWebRTC { private mediaStream: MediaStream | null = null; private streamName: string; private onTrackCallback: ((stream: MediaStream) => void) | null = null; + private onDisconnectCallback: (() => void) | null = null; + private disposed = false; constructor(streamName: string) { this.streamName = streamName; } - async connect(onTrack: (stream: MediaStream) => void): Promise { + async connect( + onTrack: (stream: MediaStream) => void, + onDisconnect?: () => void, + ): Promise { this.onTrackCallback = onTrack; + this.onDisconnectCallback = onDisconnect ?? null; + + // Clean up any existing connection before reconnecting + this.cleanupConnection(); // Create peer connection with STUN servers const config: RTCConfiguration = { @@ -29,6 +38,7 @@ export class Go2RTCWebRTC { // Handle incoming tracks this.peerConnection.ontrack = (event) => { + if (this.disposed) return; console.log(`Received track for ${this.streamName}:`, event.track.kind); if (event.streams && event.streams[0]) { this.mediaStream = event.streams[0]; @@ -44,9 +54,13 @@ export class Go2RTCWebRTC { } }; - // Handle connection state changes + // Handle connection state changes - notify on disconnect/failure this.peerConnection.onconnectionstatechange = () => { - console.log(`WebRTC connection state for ${this.streamName}:`, this.peerConnection?.connectionState); + const state = this.peerConnection?.connectionState; + console.log(`WebRTC connection state for ${this.streamName}:`, state); + if (!this.disposed && (state === 'disconnected' || state === 'failed')) { + this.onDisconnectCallback?.(); + } }; // Add transceiver for video (receive only) @@ -62,6 +76,20 @@ export class Go2RTCWebRTC { await this.peerConnection.setRemoteDescription(answer); } + private cleanupConnection(): void { + if (this.mediaStream) { + this.mediaStream.getTracks().forEach((track) => track.stop()); + this.mediaStream = null; + } + if (this.peerConnection) { + this.peerConnection.ontrack = null; + this.peerConnection.onicecandidate = null; + this.peerConnection.onconnectionstatechange = null; + this.peerConnection.close(); + this.peerConnection = null; + } + } + private async sendOffer(offer: RTCSessionDescriptionInit): Promise { const url = `${env.go2rtcUrl}/api/webrtc?src=${encodeURIComponent(this.streamName)}`; @@ -91,17 +119,10 @@ export class Go2RTCWebRTC { } disconnect(): void { - if (this.mediaStream) { - this.mediaStream.getTracks().forEach((track) => track.stop()); - this.mediaStream = null; - } - - if (this.peerConnection) { - this.peerConnection.close(); - this.peerConnection = null; - } - + this.disposed = true; + this.cleanupConnection(); this.onTrackCallback = null; + this.onDisconnectCallback = null; } getMediaStream(): MediaStream | null { diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index e9d7e48..6004193 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; // Increment this when default cameras/config changes to force refresh -const CONFIG_VERSION = 8; +const CONFIG_VERSION = 9; export interface ThermostatConfig { entityId: string;