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:
@@ -6,12 +6,14 @@ import { ScreenManager } from './services/ScreenManager';
|
|||||||
import { PresenceDetector } from './services/PresenceDetector';
|
import { PresenceDetector } from './services/PresenceDetector';
|
||||||
import { FrigateStreamer } from './services/FrigateStreamer';
|
import { FrigateStreamer } from './services/FrigateStreamer';
|
||||||
import { PhotoManager } from './services/PhotoManager';
|
import { PhotoManager } from './services/PhotoManager';
|
||||||
|
import { FrigateDetector } from './services/FrigateDetector';
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let screenManager: ScreenManager | null = null;
|
let screenManager: ScreenManager | null = null;
|
||||||
let presenceDetector: PresenceDetector | null = null;
|
let presenceDetector: PresenceDetector | null = null;
|
||||||
let frigateStreamer: FrigateStreamer | null = null;
|
let frigateStreamer: FrigateStreamer | null = null;
|
||||||
let photoManager: PhotoManager | null = null;
|
let photoManager: PhotoManager | null = null;
|
||||||
|
let frigateDetector: FrigateDetector | null = null;
|
||||||
let powerSaveBlockerId: number | null = null;
|
let powerSaveBlockerId: number | null = null;
|
||||||
|
|
||||||
// Photos directory: env var PHOTOS_PATH wins, else fall back to ~/Pictures/dashboard
|
// Photos directory: env var PHOTOS_PATH wins, else fall back to ~/Pictures/dashboard
|
||||||
@@ -66,6 +68,20 @@ function createWindow(): void {
|
|||||||
photoManager = new PhotoManager(resolvePhotosDir());
|
photoManager = new PhotoManager(resolvePhotosDir());
|
||||||
console.log(`PhotoManager: watching ${photoManager.getDir()}`);
|
console.log(`PhotoManager: watching ${photoManager.getDir()}`);
|
||||||
|
|
||||||
|
// Frigate person detection via MQTT (bypasses HA entities)
|
||||||
|
frigateDetector = new FrigateDetector({
|
||||||
|
mqttUrl: 'mqtt://192.168.1.50:1883',
|
||||||
|
mqttUser: 'mqtt',
|
||||||
|
mqttPassword: '11xpfcryan',
|
||||||
|
topicPrefix: 'frigate',
|
||||||
|
cameras: ['Front_Porch', 'FPE', 'Porch_Downstairs', 'Driveway_door'],
|
||||||
|
});
|
||||||
|
frigateDetector.on('personDetected', (camera: string) => {
|
||||||
|
console.log(`FrigateDetector: person on ${camera}`);
|
||||||
|
mainWindow?.webContents.send('frigate:personDetected', camera);
|
||||||
|
});
|
||||||
|
frigateDetector.start();
|
||||||
|
|
||||||
// Handle window close
|
// Handle window close
|
||||||
mainWindow.on('closed', () => {
|
mainWindow.on('closed', () => {
|
||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
@@ -225,6 +241,7 @@ app.on('window-all-closed', () => {
|
|||||||
}
|
}
|
||||||
presenceDetector?.stop();
|
presenceDetector?.stop();
|
||||||
frigateStreamer?.stop();
|
frigateStreamer?.stop();
|
||||||
|
frigateDetector?.stop();
|
||||||
|
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
app.quit();
|
app.quit();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface ElectronAPI {
|
|||||||
frigate: {
|
frigate: {
|
||||||
startStream: (rtspUrl: string) => Promise<boolean>;
|
startStream: (rtspUrl: string) => Promise<boolean>;
|
||||||
stopStream: () => Promise<boolean>;
|
stopStream: () => Promise<boolean>;
|
||||||
|
onPersonDetected: (callback: (camera: string) => void) => () => void;
|
||||||
};
|
};
|
||||||
app: {
|
app: {
|
||||||
quit: () => void;
|
quit: () => void;
|
||||||
@@ -57,6 +58,11 @@ const electronAPI: ElectronAPI = {
|
|||||||
frigate: {
|
frigate: {
|
||||||
startStream: (rtspUrl: string) => ipcRenderer.invoke('frigate:startStream', rtspUrl),
|
startStream: (rtspUrl: string) => ipcRenderer.invoke('frigate:startStream', rtspUrl),
|
||||||
stopStream: () => ipcRenderer.invoke('frigate:stopStream'),
|
stopStream: () => ipcRenderer.invoke('frigate:stopStream'),
|
||||||
|
onPersonDetected: (callback: (camera: string) => void) => {
|
||||||
|
const handler = (_event: IpcRendererEvent, camera: string) => callback(camera);
|
||||||
|
ipcRenderer.on('frigate:personDetected', handler);
|
||||||
|
return () => ipcRenderer.removeListener('frigate:personDetected', handler);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
quit: () => ipcRenderer.invoke('app:quit'),
|
quit: () => ipcRenderer.invoke('app:quit'),
|
||||||
|
|||||||
106
electron/services/FrigateDetector.ts
Normal file
106
electron/services/FrigateDetector.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import mqtt from 'mqtt';
|
||||||
|
|
||||||
|
interface FrigateDetectorConfig {
|
||||||
|
mqttUrl: string;
|
||||||
|
mqttUser?: string;
|
||||||
|
mqttPassword?: string;
|
||||||
|
topicPrefix?: string;
|
||||||
|
cameras: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to Frigate's MQTT events directly and emits 'personDetected'
|
||||||
|
* with the camera name when a new person event starts on a watched camera.
|
||||||
|
*/
|
||||||
|
export class FrigateDetector extends EventEmitter {
|
||||||
|
private client: mqtt.MqttClient | null = null;
|
||||||
|
private config: FrigateDetectorConfig;
|
||||||
|
private seenEvents = new Set<string>();
|
||||||
|
|
||||||
|
constructor(config: FrigateDetectorConfig) {
|
||||||
|
super();
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
const { mqttUrl, mqttUser, mqttPassword, topicPrefix = 'frigate' } = this.config;
|
||||||
|
const opts: mqtt.IClientOptions = {
|
||||||
|
clientId: `icc-frigate-${Date.now()}`,
|
||||||
|
reconnectPeriod: 5000,
|
||||||
|
connectTimeout: 10000,
|
||||||
|
};
|
||||||
|
if (mqttUser) opts.username = mqttUser;
|
||||||
|
if (mqttPassword) opts.password = mqttPassword;
|
||||||
|
|
||||||
|
this.client = mqtt.connect(mqttUrl, opts);
|
||||||
|
|
||||||
|
this.client.on('connect', () => {
|
||||||
|
console.log('FrigateDetector: MQTT connected');
|
||||||
|
// Subscribe to per-camera person topic for each watched camera
|
||||||
|
for (const cam of this.config.cameras) {
|
||||||
|
this.client!.subscribe(`${topicPrefix}/${cam}/person`, { qos: 0 });
|
||||||
|
}
|
||||||
|
// Also subscribe to events topic for richer event data
|
||||||
|
this.client!.subscribe(`${topicPrefix}/events`, { qos: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('message', (topic: string, payload: Buffer) => {
|
||||||
|
try {
|
||||||
|
const prefix = this.config.topicPrefix || 'frigate';
|
||||||
|
|
||||||
|
// frigate/<camera>/person → payload is a count (0 or 1+)
|
||||||
|
const personMatch = topic.match(new RegExp(`^${prefix}/(.+)/person$`));
|
||||||
|
if (personMatch) {
|
||||||
|
const cam = personMatch[1];
|
||||||
|
const count = parseInt(payload.toString(), 10);
|
||||||
|
if (count > 0) {
|
||||||
|
const key = `person-${cam}`;
|
||||||
|
if (!this.seenEvents.has(key)) {
|
||||||
|
this.seenEvents.add(key);
|
||||||
|
console.log(`FrigateDetector: person detected on ${cam}`);
|
||||||
|
this.emit('personDetected', cam);
|
||||||
|
// Clear after 60s so a new detection can trigger again
|
||||||
|
setTimeout(() => this.seenEvents.delete(key), 60000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Person left — allow re-trigger
|
||||||
|
this.seenEvents.delete(`person-${cam}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// frigate/events → JSON with event details
|
||||||
|
if (topic === `${prefix}/events`) {
|
||||||
|
const data = JSON.parse(payload.toString());
|
||||||
|
const after = data?.after || data;
|
||||||
|
if (after?.label === 'person' && data?.type === 'new') {
|
||||||
|
const cam = after.camera;
|
||||||
|
if (this.config.cameras.some((c) => c.toLowerCase() === cam?.toLowerCase())) {
|
||||||
|
const key = `event-${after.id}`;
|
||||||
|
if (!this.seenEvents.has(key)) {
|
||||||
|
this.seenEvents.add(key);
|
||||||
|
this.emit('personDetected', cam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('FrigateDetector: parse error', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('error', (err: Error) => {
|
||||||
|
console.error('FrigateDetector: MQTT error', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('reconnect', () => {
|
||||||
|
console.log('FrigateDetector: MQTT reconnecting...');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.client?.end(true);
|
||||||
|
this.client = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
8091
package-lock.json
generated
Normal file
8091
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -18,26 +18,27 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"home-assistant-js-websocket": "^9.4.0",
|
|
||||||
"zustand": "^4.5.0",
|
|
||||||
"googleapis": "^131.0.0",
|
|
||||||
"@tensorflow/tfjs": "^4.17.0",
|
|
||||||
"@tensorflow-models/coco-ssd": "^2.2.3",
|
"@tensorflow-models/coco-ssd": "^2.2.3",
|
||||||
|
"@tensorflow/tfjs": "^4.17.0",
|
||||||
|
"date-fns": "^3.3.1",
|
||||||
"electron-store": "^8.1.0",
|
"electron-store": "^8.1.0",
|
||||||
"date-fns": "^3.3.1"
|
"googleapis": "^131.0.0",
|
||||||
|
"home-assistant-js-websocket": "^9.4.0",
|
||||||
|
"mqtt": "^5.15.1",
|
||||||
|
"zustand": "^4.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.2.48",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||||
|
"@typescript-eslint/parser": "^6.19.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"autoprefixer": "^10.4.17",
|
"autoprefixer": "^10.4.17",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"electron": "^28.2.0",
|
"electron": "^28.2.0",
|
||||||
"electron-builder": "^24.9.1",
|
"electron-builder": "^24.9.1",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
|
||||||
"@typescript-eslint/parser": "^6.19.0",
|
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"postcss": "^8.4.33",
|
"postcss": "^8.4.33",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
48
src/App.tsx
48
src/App.tsx
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Dashboard } from '@/components/layout';
|
import { Dashboard } from '@/components/layout';
|
||||||
import { ThermostatOverlay } from '@/components/climate';
|
import { ThermostatOverlay } from '@/components/climate';
|
||||||
import { LightsOverlay } from '@/components/lights';
|
import { LightsOverlay } from '@/components/lights';
|
||||||
@@ -14,19 +14,11 @@ import { GlobalKeyboard } from '@/components/keyboard';
|
|||||||
import { PhotoFrame } from '@/components/photoframe';
|
import { PhotoFrame } from '@/components/photoframe';
|
||||||
import { useHomeAssistant } from '@/hooks';
|
import { useHomeAssistant } from '@/hooks';
|
||||||
import { useIdle } from '@/hooks/useIdle';
|
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 { useHAStore } from '@/stores/haStore';
|
||||||
import { useUIStore, useCameraOverlay } from '@/stores/uiStore';
|
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';
|
||||||
|
|
||||||
// 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
|
// Person detection alert overlay - shows for 30 seconds when person detected on any configured camera
|
||||||
function PersonAlert({ cameraName, onClose }: { cameraName: string; onClose: () => void }) {
|
function PersonAlert({ cameraName, onClose }: { cameraName: string; onClose: () => void }) {
|
||||||
@@ -116,10 +108,9 @@ function DashboardContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { isConnected, connectionState } = useHomeAssistant();
|
const { connectionState } = useHomeAssistant();
|
||||||
const accessToken = useHAStore((state) => state.accessToken);
|
const accessToken = useHAStore((state) => state.accessToken);
|
||||||
const connect = useHAStore((state) => state.connect);
|
const connect = useHAStore((state) => state.connect);
|
||||||
const entities = useHAStore((state) => state.entities);
|
|
||||||
const settingsOpen = useUIStore((state) => state.settingsOpen);
|
const settingsOpen = useUIStore((state) => state.settingsOpen);
|
||||||
const lightsOverlayOpen = useUIStore((state) => state.lightsOverlayOpen);
|
const lightsOverlayOpen = useUIStore((state) => state.lightsOverlayOpen);
|
||||||
const locksOverlayOpen = useUIStore((state) => state.locksOverlayOpen);
|
const locksOverlayOpen = useUIStore((state) => state.locksOverlayOpen);
|
||||||
@@ -129,10 +120,8 @@ export default function App() {
|
|||||||
const { isOpen: cameraOverlayOpen } = useCameraOverlay();
|
const { isOpen: cameraOverlayOpen } = useCameraOverlay();
|
||||||
const isIdle = useIdle(env.photoFrameIdleTimeout);
|
const isIdle = useIdle(env.photoFrameIdleTimeout);
|
||||||
|
|
||||||
// Person detection alert state
|
// Person detection alert state (via MQTT from Electron main process)
|
||||||
const personDetectionEntities = useSettingsStore((state) => state.config.personDetectionEntities);
|
|
||||||
const [alertCamera, setAlertCamera] = useState<string | null>(null);
|
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
|
// Report touch/click activity to main process for screen wake on Wayland
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -183,29 +172,16 @@ export default function App() {
|
|||||||
initConfig();
|
initConfig();
|
||||||
}, [accessToken, connect]);
|
}, [accessToken, connect]);
|
||||||
|
|
||||||
// Listen for person detection on all configured entities
|
// Listen for person detection via MQTT (from Electron main process)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isConnected) return;
|
const api = window.electronAPI;
|
||||||
|
if (!api?.frigate?.onPersonDetected) return;
|
||||||
for (const entityId of personDetectionEntities) {
|
const unsub = api.frigate.onPersonDetected((camera: string) => {
|
||||||
const entity = entities[entityId];
|
setAlertCamera(camera);
|
||||||
const isDetected = entity?.state === 'on';
|
useUIStore.getState().setIdle(false);
|
||||||
const cameraName = entityToCameraName(entityId);
|
});
|
||||||
|
return unsub;
|
||||||
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 closePersonAlert = useCallback(() => {
|
const closePersonAlert = useCallback(() => {
|
||||||
setAlertCamera(null);
|
setAlertCamera(null);
|
||||||
|
|||||||
60
src/hooks/useFrigateDetection.ts
Normal file
60
src/hooks/useFrigateDetection.ts
Normal 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
1
src/vite-env.d.ts
vendored
@@ -36,6 +36,7 @@ interface ElectronAPI {
|
|||||||
frigate: {
|
frigate: {
|
||||||
startStream: (rtspUrl: string) => Promise<boolean>;
|
startStream: (rtspUrl: string) => Promise<boolean>;
|
||||||
stopStream: () => Promise<boolean>;
|
stopStream: () => Promise<boolean>;
|
||||||
|
onPersonDetected: (callback: (camera: string) => void) => () => void;
|
||||||
};
|
};
|
||||||
app: {
|
app: {
|
||||||
quit: () => void;
|
quit: () => void;
|
||||||
|
|||||||
Reference in New Issue
Block a user