Person detection via direct MQTT instead of HA entities

- Electron main process subscribes to Frigate's MQTT topics
  (frigate/<camera>/person and frigate/events) directly via mqtt.js,
  bypassing the broken HA MQTT integration
- Watched cameras: Front_Porch, FPE, Porch_Downstairs, Driveway_door
- On person detection, exits photo-frame idle and shows full-screen
  camera feed for 30 seconds
- Removed HA entity-based person detection code (entityToCameraName,
  personDetectionEntities config dependency)
- Deleted unused useFrigateDetection HTTP polling hook (superseded)
This commit is contained in:
root
2026-04-16 21:46:28 -05:00
parent 55dd117520
commit 3b38a78295
8 changed files with 8301 additions and 43 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Dashboard } from '@/components/layout';
import { ThermostatOverlay } from '@/components/climate';
import { LightsOverlay } from '@/components/lights';
@@ -14,19 +14,11 @@ import { GlobalKeyboard } from '@/components/keyboard';
import { PhotoFrame } from '@/components/photoframe';
import { useHomeAssistant } from '@/hooks';
import { useIdle } from '@/hooks/useIdle';
// Motion detection now runs in Electron main process (MotionDetector.ts)
// import { useSimpleMotion } from '@/hooks/useSimpleMotion';
import { useHAStore } from '@/stores/haStore';
import { useUIStore, useCameraOverlay } from '@/stores/uiStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { env } from '@/config/environment';
// 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 }) {
@@ -116,10 +108,9 @@ function DashboardContent() {
}
export default function App() {
const { isConnected, connectionState } = useHomeAssistant();
const { connectionState } = useHomeAssistant();
const accessToken = useHAStore((state) => state.accessToken);
const connect = useHAStore((state) => state.connect);
const entities = useHAStore((state) => state.entities);
const settingsOpen = useUIStore((state) => state.settingsOpen);
const lightsOverlayOpen = useUIStore((state) => state.lightsOverlayOpen);
const locksOverlayOpen = useUIStore((state) => state.locksOverlayOpen);
@@ -129,10 +120,8 @@ export default function App() {
const { isOpen: cameraOverlayOpen } = useCameraOverlay();
const isIdle = useIdle(env.photoFrameIdleTimeout);
// Person detection alert state
const personDetectionEntities = useSettingsStore((state) => state.config.personDetectionEntities);
// Person detection alert state (via MQTT from Electron main process)
const [alertCamera, setAlertCamera] = useState<string | null>(null);
const alertShownForRef = useRef<Set<string>>(new Set());
// Report touch/click activity to main process for screen wake on Wayland
useEffect(() => {
@@ -183,29 +172,16 @@ export default function App() {
initConfig();
}, [accessToken, connect]);
// Listen for person detection on all configured entities
// Listen for person detection via MQTT (from Electron main process)
useEffect(() => {
if (!isConnected) return;
for (const entityId of personDetectionEntities) {
const entity = entities[entityId];
const isDetected = entity?.state === 'on';
const cameraName = entityToCameraName(entityId);
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);
}
}
}, [isConnected, entities, personDetectionEntities]);
const api = window.electronAPI;
if (!api?.frigate?.onPersonDetected) return;
const unsub = api.frigate.onPersonDetected((camera: string) => {
setAlertCamera(camera);
useUIStore.getState().setIdle(false);
});
return unsub;
}, []);
const closePersonAlert = useCallback(() => {
setAlertCamera(null);

View File

@@ -0,0 +1,60 @@
import { useEffect, useRef, useCallback } from 'react';
import { env } from '@/config/environment';
interface FrigateEvent {
id: string;
camera: string;
label: string;
start_time: number;
end_time: number | null;
}
/**
* Polls Frigate's /api/events for active (end_time=null) person events
* on the specified cameras. Fires onPersonDetected with the camera name
* when a NEW event appears. Bypasses MQTT/HA entirely.
*/
export function useFrigateDetection({
cameras,
onPersonDetected,
pollIntervalMs = 5000,
}: {
cameras: string[];
onPersonDetected: (camera: string) => void;
pollIntervalMs?: number;
}) {
const seenRef = useRef<Set<string>>(new Set());
const onDetectRef = useRef(onPersonDetected);
onDetectRef.current = onPersonDetected;
const poll = useCallback(async () => {
if (!cameras.length) return;
try {
const url = `${env.frigateUrl}/api/events?labels=person&limit=5&has_clip=0&in_progress=1`;
const resp = await fetch(url);
if (!resp.ok) return;
const events: FrigateEvent[] = await resp.json();
for (const ev of events) {
if (ev.end_time !== null) continue; // already ended
if (!cameras.some((c) => c.toLowerCase() === ev.camera.toLowerCase())) continue;
if (seenRef.current.has(ev.id)) continue;
seenRef.current.add(ev.id);
onDetectRef.current(ev.camera);
break; // one at a time
}
// Prune old seen IDs (keep last 50)
if (seenRef.current.size > 50) {
const arr = [...seenRef.current];
seenRef.current = new Set(arr.slice(arr.length - 25));
}
} catch {
// Frigate unreachable — skip silently
}
}, [cameras]);
useEffect(() => {
poll();
const id = setInterval(poll, pollIntervalMs);
return () => clearInterval(id);
}, [poll, pollIntervalMs]);
}

1
src/vite-env.d.ts vendored
View File

@@ -36,6 +36,7 @@ interface ElectronAPI {
frigate: {
startStream: (rtspUrl: string) => Promise<boolean>;
stopStream: () => Promise<boolean>;
onPersonDetected: (callback: (camera: string) => void) => () => void;
};
app: {
quit: () => void;