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
This commit is contained in:
root
2026-02-26 15:33:25 -06:00
parent 97a7912eae
commit 58ebd3e239
4 changed files with 151 additions and 64 deletions

View File

@@ -19,17 +19,26 @@ import { useUIStore, useCameraOverlay } from '@/stores/uiStore';
import { useSettingsStore } from '@/stores/settingsStore'; import { useSettingsStore } from '@/stores/settingsStore';
import { env } from '@/config/environment'; import { env } from '@/config/environment';
// Front porch alert overlay - shows for 30 seconds when person detected // Map a Frigate person_occupancy entity to its camera name
function FrontPorchAlert({ onClose }: { onClose: () => void }) { // 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 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(() => { useEffect(() => {
const timer = setTimeout(onClose, 30000); // 30 seconds const timer = setTimeout(onClose, 30000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [onClose]); }, [onClose]);
if (!frontPorchCamera) return null; if (!camera) return null;
return ( return (
<div className="fixed inset-0 z-50 bg-black flex flex-col"> <div className="fixed inset-0 z-50 bg-black flex flex-col">
@@ -37,7 +46,7 @@ function FrontPorchAlert({ onClose }: { onClose: () => void }) {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-3 h-3 rounded-full bg-status-warning animate-pulse" /> <div className="w-3 h-3 rounded-full bg-status-warning animate-pulse" />
<h2 className="text-lg font-semibold text-status-warning"> <h2 className="text-lg font-semibold text-status-warning">
Person Detected - Front Porch Person Detected - {camera.displayName}
</h2> </h2>
</div> </div>
<button <button
@@ -51,7 +60,7 @@ function FrontPorchAlert({ onClose }: { onClose: () => void }) {
</div> </div>
<div className="flex-1 p-2"> <div className="flex-1 p-2">
<CameraFeed <CameraFeed
camera={frontPorchCamera} camera={camera}
className="w-full h-full" className="w-full h-full"
showLabel={false} showLabel={false}
/> />
@@ -174,9 +183,10 @@ export default function App() {
const mediaOverlayOpen = useUIStore((state) => state.mediaOverlayOpen); const mediaOverlayOpen = useUIStore((state) => state.mediaOverlayOpen);
const { isOpen: cameraOverlayOpen } = useCameraOverlay(); const { isOpen: cameraOverlayOpen } = useCameraOverlay();
// Front porch alert state // Person detection alert state
const [showFrontPorchAlert, setShowFrontPorchAlert] = useState(false); const personDetectionEntities = useSettingsStore((state) => state.config.personDetectionEntities);
const frontPorchAlertShownRef = useRef(false); const [alertCamera, setAlertCamera] = useState<string | null>(null);
const alertShownForRef = useRef<Set<string>>(new Set());
// Motion detection now runs in the Electron main process (MotionDetector.ts) // Motion detection now runs in the Electron main process (MotionDetector.ts)
// This prevents browser throttling when the screensaver is active // This prevents browser throttling when the screensaver is active
@@ -230,29 +240,32 @@ export default function App() {
initConfig(); initConfig();
}, [accessToken, connect]); }, [accessToken, connect]);
// Listen for Front Porch person detection - show full screen overlay for 30 seconds // Listen for person detection on all configured entities
useEffect(() => { useEffect(() => {
if (!isConnected) return; if (!isConnected) return;
const frontPorchEntity = entities['binary_sensor.front_porch_person_occupancy']; for (const entityId of personDetectionEntities) {
const isPersonDetected = frontPorchEntity?.state === 'on'; const entity = entities[entityId];
const isDetected = entity?.state === 'on';
const cameraName = entityToCameraName(entityId);
if (isPersonDetected && !frontPorchAlertShownRef.current) { if (isDetected && cameraName && !alertShownForRef.current.has(entityId)) {
// Person just detected - show alert // Person just detected on this camera - show alert
frontPorchAlertShownRef.current = true; alertShownForRef.current.add(entityId);
setShowFrontPorchAlert(true); setAlertCamera(cameraName);
// Also wake the screen
if (window.electronAPI?.screen?.wake) { if (window.electronAPI?.screen?.wake) {
window.electronAPI.screen.wake(); window.electronAPI.screen.wake();
} }
} else if (!isPersonDetected) { break; // Show one alert at a time
// Reset flag when person clears so next detection triggers alert } else if (!isDetected) {
frontPorchAlertShownRef.current = false; // Reset so next detection on this entity triggers again
alertShownForRef.current.delete(entityId);
} }
}, [isConnected, entities]); }
}, [isConnected, entities, personDetectionEntities]);
const closeFrontPorchAlert = useCallback(() => { const closePersonAlert = useCallback(() => {
setShowFrontPorchAlert(false); setAlertCamera(null);
}, []); }, []);
// Set up screen idle timeout // Set up screen idle timeout
@@ -321,7 +334,7 @@ export default function App() {
{mediaOverlayOpen && <JellyfinOverlay />} {mediaOverlayOpen && <JellyfinOverlay />}
{cameraOverlayOpen && <CameraOverlay />} {cameraOverlayOpen && <CameraOverlay />}
{settingsOpen && <SettingsPanel />} {settingsOpen && <SettingsPanel />}
{showFrontPorchAlert && <FrontPorchAlert onClose={closeFrontPorchAlert} />} {alertCamera && <PersonAlert cameraName={alertCamera} onClose={closePersonAlert} />}
<GlobalKeyboard /> <GlobalKeyboard />
</> </>
); );

View File

@@ -1,7 +1,10 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState, useCallback } from 'react';
import { Go2RTCWebRTC } from '@/services/go2rtc'; import { Go2RTCWebRTC } from '@/services/go2rtc';
import { CameraConfig } from '@/config/entities'; import { CameraConfig } from '@/config/entities';
const RECONNECT_DELAY_MS = 3000;
const MAX_RECONNECT_DELAY_MS = 30000;
interface CameraFeedProps { interface CameraFeedProps {
camera: CameraConfig; camera: CameraConfig;
onClick?: () => void; onClick?: () => void;
@@ -21,50 +24,99 @@ export function CameraFeed({
}: CameraFeedProps) { }: CameraFeedProps) {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const webrtcRef = useRef<Go2RTCWebRTC | null>(null); const webrtcRef = useRef<Go2RTCWebRTC | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout>>();
const reconnectDelayRef = useRef(RECONNECT_DELAY_MS);
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);
// Use substream for grid view (lower bandwidth) // Use substream for grid view (lower bandwidth)
const streamName = useSubstream ? `${camera.go2rtcStream}_sub` : camera.go2rtcStream; const streamName = useSubstream ? `${camera.go2rtcStream}_sub` : camera.go2rtcStream;
useEffect(() => { const connect = useCallback(async () => {
let mounted = true; if (!mountedRef.current) return;
let timeoutId: ReturnType<typeof setTimeout>;
const connect = async () => {
try { try {
setIsConnecting(true); setIsConnecting(true);
setError(null); setError(null);
webrtcRef.current = new Go2RTCWebRTC(streamName); // Disconnect previous instance
webrtcRef.current?.disconnect();
await webrtcRef.current.connect((stream) => { const webrtc = new Go2RTCWebRTC(streamName);
if (mounted && videoRef.current) { webrtcRef.current = webrtc;
await webrtc.connect(
// onTrack
(stream) => {
if (mountedRef.current && videoRef.current) {
videoRef.current.srcObject = stream; videoRef.current.srcObject = stream;
setIsConnecting(false); setIsConnecting(false);
setError(null);
// Reset backoff on successful connection
reconnectDelayRef.current = RECONNECT_DELAY_MS;
} }
}); },
// 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) { } catch (err) {
if (mounted) { if (!mountedRef.current) return;
setError(err instanceof Error ? err.message : 'Failed to connect'); const msg = err instanceof Error ? err.message : 'Failed to connect';
setError(msg);
setIsConnecting(false); 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<typeof setTimeout>;
// Stagger connections to avoid overwhelming go2rtc // Stagger connections to avoid overwhelming go2rtc
if (delayMs > 0) { if (delayMs > 0) {
timeoutId = setTimeout(connect, delayMs); initTimer = setTimeout(connect, delayMs);
} else { } else {
connect(); connect();
} }
return () => { return () => {
mounted = false; mountedRef.current = false;
if (timeoutId) clearTimeout(timeoutId); clearTimeout(initTimer);
clearTimeout(reconnectTimerRef.current);
webrtcRef.current?.disconnect(); webrtcRef.current?.disconnect();
}; };
}, [streamName, delayMs]); }, [connect, delayMs]);
return ( return (
<div <div
@@ -93,13 +145,14 @@ export function CameraFeed({
)} )}
{/* Error Overlay */} {/* Error Overlay */}
{error && ( {error && !isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-imperial-black/80"> <div className="absolute inset-0 flex items-center justify-center bg-imperial-black/80">
<div className="flex flex-col items-center gap-2 text-center p-4"> <div className="flex flex-col items-center gap-2 text-center p-4">
<svg className="w-8 h-8 text-status-error" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-8 h-8 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-sm text-gray-400">{error}</span> <span className="text-sm text-gray-400">{error}</span>
<span className="text-xs text-gray-500">Reconnecting...</span>
</div> </div>
</div> </div>
)} )}

View File

@@ -10,13 +10,22 @@ export class Go2RTCWebRTC {
private mediaStream: MediaStream | null = null; private mediaStream: MediaStream | null = null;
private streamName: string; private streamName: string;
private onTrackCallback: ((stream: MediaStream) => void) | null = null; private onTrackCallback: ((stream: MediaStream) => void) | null = null;
private onDisconnectCallback: (() => void) | null = null;
private disposed = false;
constructor(streamName: string) { constructor(streamName: 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.onTrackCallback = onTrack; this.onTrackCallback = onTrack;
this.onDisconnectCallback = onDisconnect ?? null;
// Clean up any existing connection before reconnecting
this.cleanupConnection();
// Create peer connection with STUN servers // Create peer connection with STUN servers
const config: RTCConfiguration = { const config: RTCConfiguration = {
@@ -29,6 +38,7 @@ export class Go2RTCWebRTC {
// Handle incoming tracks // Handle incoming tracks
this.peerConnection.ontrack = (event) => { this.peerConnection.ontrack = (event) => {
if (this.disposed) return;
console.log(`Received track for ${this.streamName}:`, event.track.kind); console.log(`Received track for ${this.streamName}:`, event.track.kind);
if (event.streams && event.streams[0]) { if (event.streams && event.streams[0]) {
this.mediaStream = 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 = () => { 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) // Add transceiver for video (receive only)
@@ -62,6 +76,20 @@ export class Go2RTCWebRTC {
await this.peerConnection.setRemoteDescription(answer); 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<RTCSessionDescriptionInit> { private async sendOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
const url = `${env.go2rtcUrl}/api/webrtc?src=${encodeURIComponent(this.streamName)}`; const url = `${env.go2rtcUrl}/api/webrtc?src=${encodeURIComponent(this.streamName)}`;
@@ -91,17 +119,10 @@ export class Go2RTCWebRTC {
} }
disconnect(): void { disconnect(): void {
if (this.mediaStream) { this.disposed = true;
this.mediaStream.getTracks().forEach((track) => track.stop()); this.cleanupConnection();
this.mediaStream = null;
}
if (this.peerConnection) {
this.peerConnection.close();
this.peerConnection = null;
}
this.onTrackCallback = null; this.onTrackCallback = null;
this.onDisconnectCallback = null;
} }
getMediaStream(): MediaStream | null { getMediaStream(): MediaStream | null {

View File

@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
// Increment this when default cameras/config changes to force refresh // Increment this when default cameras/config changes to force refresh
const CONFIG_VERSION = 8; const CONFIG_VERSION = 9;
export interface ThermostatConfig { export interface ThermostatConfig {
entityId: string; entityId: string;