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;