Initial commit: Electron + React touchscreen kiosk dashboard for Home Assistant
This commit is contained in:
229
electron/main.ts
Normal file
229
electron/main.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { app, BrowserWindow, ipcMain, screen, powerSaveBlocker } from 'electron';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { ScreenManager } from './services/ScreenManager';
|
||||
import { PresenceDetector } from './services/PresenceDetector';
|
||||
import { FrigateStreamer } from './services/FrigateStreamer';
|
||||
import { MotionDetector } from './services/MotionDetector';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let screenManager: ScreenManager | null = null;
|
||||
let presenceDetector: PresenceDetector | null = null;
|
||||
let frigateStreamer: FrigateStreamer | null = null;
|
||||
let motionDetector: MotionDetector | null = null;
|
||||
let powerSaveBlockerId: number | null = null;
|
||||
|
||||
// Check if we're in dev mode: explicitly set NODE_ENV or running from source with vite dev server
|
||||
// When running from unpacked folder, app.isPackaged is false but we still want production behavior
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
function createWindow(): void {
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
const { width, height } = primaryDisplay.workAreaSize;
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width,
|
||||
height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
fullscreen: true,
|
||||
kiosk: !isDev, // Kiosk mode in production
|
||||
frame: false,
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: '#0a0a0a',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: false, // Required for some electron features
|
||||
},
|
||||
});
|
||||
|
||||
// Load the app
|
||||
if (isDev) {
|
||||
mainWindow.loadURL('http://localhost:5173');
|
||||
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||
} else {
|
||||
// __dirname is dist-electron, so go up one level to reach dist
|
||||
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
|
||||
}
|
||||
|
||||
// Prevent power save mode
|
||||
powerSaveBlockerId = powerSaveBlocker.start('prevent-display-sleep');
|
||||
|
||||
// Initialize services
|
||||
screenManager = new ScreenManager(mainWindow);
|
||||
|
||||
// Initialize motion detector (runs in main process, not throttled by browser)
|
||||
// Uses file size comparison which is more reliable for JPEG streams
|
||||
motionDetector = new MotionDetector({
|
||||
go2rtcUrl: 'http://192.168.1.241:1985',
|
||||
cameraName: 'Kitchen_Panel',
|
||||
sensitivityThreshold: 5, // % file size change to trigger (5% = significant motion)
|
||||
checkIntervalMs: 2000, // Check every 2 seconds for responsiveness
|
||||
});
|
||||
|
||||
motionDetector.on('motion', () => {
|
||||
console.log('MotionDetector: Motion detected, waking screen');
|
||||
screenManager?.wakeScreen();
|
||||
mainWindow?.webContents.send('motion:detected');
|
||||
});
|
||||
|
||||
motionDetector.start();
|
||||
|
||||
// Handle window close
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
// Prevent accidental navigation
|
||||
mainWindow.webContents.on('will-navigate', (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// Handle external links
|
||||
mainWindow.webContents.setWindowOpenHandler(() => {
|
||||
return { action: 'deny' };
|
||||
});
|
||||
}
|
||||
|
||||
// IPC handlers
|
||||
function setupIpcHandlers(): void {
|
||||
// Screen management
|
||||
ipcMain.handle('screen:wake', async () => {
|
||||
await screenManager?.wakeScreen();
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('screen:sleep', async () => {
|
||||
await screenManager?.sleepScreen();
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('screen:setIdleTimeout', async (_event, timeout: number) => {
|
||||
screenManager?.setIdleTimeout(timeout);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Handle touch/click activity from renderer (for Wayland touch support)
|
||||
ipcMain.handle('screen:activity', async () => {
|
||||
screenManager?.handleUserActivity();
|
||||
return true;
|
||||
});
|
||||
|
||||
// Presence detection control
|
||||
ipcMain.handle('presence:start', async () => {
|
||||
if (!presenceDetector) {
|
||||
presenceDetector = new PresenceDetector();
|
||||
presenceDetector.on('personDetected', () => {
|
||||
mainWindow?.webContents.send('presence:detected');
|
||||
screenManager?.wakeScreen();
|
||||
});
|
||||
presenceDetector.on('noPersonDetected', () => {
|
||||
mainWindow?.webContents.send('presence:cleared');
|
||||
});
|
||||
}
|
||||
await presenceDetector.start();
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('presence:stop', async () => {
|
||||
await presenceDetector?.stop();
|
||||
return true;
|
||||
});
|
||||
|
||||
// Frigate streaming control
|
||||
ipcMain.handle('frigate:startStream', async (_event, rtspUrl: string) => {
|
||||
if (!frigateStreamer) {
|
||||
frigateStreamer = new FrigateStreamer();
|
||||
}
|
||||
await frigateStreamer.start(rtspUrl);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('frigate:stopStream', async () => {
|
||||
await frigateStreamer?.stop();
|
||||
return true;
|
||||
});
|
||||
|
||||
// Config - read stored token from file
|
||||
ipcMain.handle('config:getStoredToken', async () => {
|
||||
try {
|
||||
const tokenPath = path.join(app.getPath('userData'), 'ha_token.txt');
|
||||
if (fs.existsSync(tokenPath)) {
|
||||
const token = fs.readFileSync(tokenPath, 'utf-8').trim();
|
||||
return token || null;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Config - read Jellyfin API key from file
|
||||
ipcMain.handle('config:getJellyfinApiKey', async () => {
|
||||
try {
|
||||
const keyPath = path.join(app.getPath('userData'), 'jellyfin_api_key.txt');
|
||||
if (fs.existsSync(keyPath)) {
|
||||
const key = fs.readFileSync(keyPath, 'utf-8').trim();
|
||||
return key || null;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// App control
|
||||
ipcMain.handle('app:quit', () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
ipcMain.handle('app:toggleFullscreen', () => {
|
||||
if (mainWindow) {
|
||||
const isFullScreen = mainWindow.isFullScreen();
|
||||
mainWindow.setFullScreen(!isFullScreen);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('app:toggleDevTools', () => {
|
||||
if (isDev && mainWindow) {
|
||||
mainWindow.webContents.toggleDevTools();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// App lifecycle
|
||||
app.whenReady().then(() => {
|
||||
setupIpcHandlers();
|
||||
createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// Clean up
|
||||
if (powerSaveBlockerId !== null) {
|
||||
powerSaveBlocker.stop(powerSaveBlockerId);
|
||||
}
|
||||
presenceDetector?.stop();
|
||||
frigateStreamer?.stop();
|
||||
motionDetector?.stop();
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('Uncaught exception:', error);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('Unhandled rejection:', reason);
|
||||
});
|
||||
74
electron/preload.ts
Normal file
74
electron/preload.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
|
||||
export interface ElectronAPI {
|
||||
screen: {
|
||||
wake: () => Promise<boolean>;
|
||||
sleep: () => Promise<boolean>;
|
||||
setIdleTimeout: (timeout: number) => Promise<boolean>;
|
||||
activity: () => Promise<boolean>;
|
||||
};
|
||||
presence: {
|
||||
start: () => Promise<boolean>;
|
||||
stop: () => Promise<boolean>;
|
||||
onDetected: (callback: () => void) => () => void;
|
||||
onCleared: (callback: () => void) => () => void;
|
||||
};
|
||||
frigate: {
|
||||
startStream: (rtspUrl: string) => Promise<boolean>;
|
||||
stopStream: () => Promise<boolean>;
|
||||
};
|
||||
app: {
|
||||
quit: () => void;
|
||||
toggleFullscreen: () => void;
|
||||
toggleDevTools: () => void;
|
||||
};
|
||||
config: {
|
||||
getStoredToken: () => Promise<string | null>;
|
||||
getJellyfinApiKey: () => Promise<string | null>;
|
||||
};
|
||||
}
|
||||
|
||||
const electronAPI: ElectronAPI = {
|
||||
screen: {
|
||||
wake: () => ipcRenderer.invoke('screen:wake'),
|
||||
sleep: () => ipcRenderer.invoke('screen:sleep'),
|
||||
setIdleTimeout: (timeout: number) => ipcRenderer.invoke('screen:setIdleTimeout', timeout),
|
||||
activity: () => ipcRenderer.invoke('screen:activity'),
|
||||
},
|
||||
presence: {
|
||||
start: () => ipcRenderer.invoke('presence:start'),
|
||||
stop: () => ipcRenderer.invoke('presence:stop'),
|
||||
onDetected: (callback: () => void) => {
|
||||
const handler = (_event: IpcRendererEvent) => callback();
|
||||
ipcRenderer.on('presence:detected', handler);
|
||||
return () => ipcRenderer.removeListener('presence:detected', handler);
|
||||
},
|
||||
onCleared: (callback: () => void) => {
|
||||
const handler = (_event: IpcRendererEvent) => callback();
|
||||
ipcRenderer.on('presence:cleared', handler);
|
||||
return () => ipcRenderer.removeListener('presence:cleared', handler);
|
||||
},
|
||||
},
|
||||
frigate: {
|
||||
startStream: (rtspUrl: string) => ipcRenderer.invoke('frigate:startStream', rtspUrl),
|
||||
stopStream: () => ipcRenderer.invoke('frigate:stopStream'),
|
||||
},
|
||||
app: {
|
||||
quit: () => ipcRenderer.invoke('app:quit'),
|
||||
toggleFullscreen: () => ipcRenderer.invoke('app:toggleFullscreen'),
|
||||
toggleDevTools: () => ipcRenderer.invoke('app:toggleDevTools'),
|
||||
},
|
||||
config: {
|
||||
getStoredToken: () => ipcRenderer.invoke('config:getStoredToken'),
|
||||
getJellyfinApiKey: () => ipcRenderer.invoke('config:getJellyfinApiKey'),
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
||||
|
||||
// Type declaration for renderer
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
}
|
||||
}
|
||||
177
electron/services/FrigateStreamer.ts
Normal file
177
electron/services/FrigateStreamer.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export class FrigateStreamer extends EventEmitter {
|
||||
private ffmpegProcess: ChildProcess | null = null;
|
||||
private isStreaming: boolean = false;
|
||||
private restartAttempts: number = 0;
|
||||
private readonly MAX_RESTART_ATTEMPTS = 5;
|
||||
private restartTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
async start(rtspOutputUrl: string): Promise<void> {
|
||||
if (this.isStreaming) {
|
||||
console.log('Already streaming to Frigate');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get camera devices
|
||||
const videoDevices = await this.getVideoDevices();
|
||||
if (videoDevices.length === 0) {
|
||||
throw new Error('No video devices found');
|
||||
}
|
||||
|
||||
const videoDevice = videoDevices[0]; // Use first available camera
|
||||
console.log(`Using video device: ${videoDevice}`);
|
||||
|
||||
// Build FFmpeg command
|
||||
const ffmpegArgs = this.buildFfmpegArgs(videoDevice, rtspOutputUrl);
|
||||
|
||||
// Start FFmpeg process
|
||||
this.ffmpegProcess = spawn('ffmpeg', ffmpegArgs, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
this.ffmpegProcess.stdout?.on('data', (data) => {
|
||||
console.log(`FFmpeg stdout: ${data}`);
|
||||
});
|
||||
|
||||
this.ffmpegProcess.stderr?.on('data', (data) => {
|
||||
// FFmpeg logs to stderr by default
|
||||
const message = data.toString();
|
||||
if (message.includes('error') || message.includes('Error')) {
|
||||
console.error(`FFmpeg error: ${message}`);
|
||||
this.emit('error', message);
|
||||
}
|
||||
});
|
||||
|
||||
this.ffmpegProcess.on('close', (code) => {
|
||||
console.log(`FFmpeg process exited with code ${code}`);
|
||||
this.isStreaming = false;
|
||||
|
||||
if (code !== 0 && this.restartAttempts < this.MAX_RESTART_ATTEMPTS) {
|
||||
this.restartAttempts++;
|
||||
console.log(`Restarting FFmpeg (attempt ${this.restartAttempts}/${this.MAX_RESTART_ATTEMPTS})`);
|
||||
this.restartTimeout = setTimeout(() => {
|
||||
this.start(rtspOutputUrl);
|
||||
}, 5000);
|
||||
} else if (this.restartAttempts >= this.MAX_RESTART_ATTEMPTS) {
|
||||
this.emit('maxRestartsReached');
|
||||
}
|
||||
});
|
||||
|
||||
this.ffmpegProcess.on('error', (error) => {
|
||||
console.error('FFmpeg process error:', error);
|
||||
this.emit('error', error);
|
||||
});
|
||||
|
||||
this.isStreaming = true;
|
||||
this.restartAttempts = 0;
|
||||
this.emit('started');
|
||||
console.log(`Streaming to ${rtspOutputUrl}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to start Frigate stream:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async getVideoDevices(): Promise<string[]> {
|
||||
return new Promise((resolve) => {
|
||||
if (process.platform === 'linux') {
|
||||
// On Linux, check for /dev/video* devices
|
||||
const { exec } = require('child_process');
|
||||
exec('ls /dev/video* 2>/dev/null', (error: Error | null, stdout: string) => {
|
||||
if (error) {
|
||||
resolve([]);
|
||||
} else {
|
||||
const devices = stdout.trim().split('\n').filter(Boolean);
|
||||
resolve(devices);
|
||||
}
|
||||
});
|
||||
} else if (process.platform === 'win32') {
|
||||
// On Windows, use DirectShow device listing
|
||||
// For simplicity, return default device name
|
||||
resolve(['video=Integrated Camera']);
|
||||
} else if (process.platform === 'darwin') {
|
||||
// On macOS, use AVFoundation
|
||||
resolve(['0']); // Device index
|
||||
} else {
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private buildFfmpegArgs(videoDevice: string, rtspOutputUrl: string): string[] {
|
||||
const baseArgs: string[] = [];
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
baseArgs.push(
|
||||
'-f', 'v4l2',
|
||||
'-input_format', 'mjpeg',
|
||||
'-framerate', '15',
|
||||
'-video_size', '640x480',
|
||||
'-i', videoDevice
|
||||
);
|
||||
} else if (process.platform === 'win32') {
|
||||
baseArgs.push(
|
||||
'-f', 'dshow',
|
||||
'-framerate', '15',
|
||||
'-video_size', '640x480',
|
||||
'-i', videoDevice
|
||||
);
|
||||
} else if (process.platform === 'darwin') {
|
||||
baseArgs.push(
|
||||
'-f', 'avfoundation',
|
||||
'-framerate', '15',
|
||||
'-video_size', '640x480',
|
||||
'-i', videoDevice
|
||||
);
|
||||
}
|
||||
|
||||
// Output settings for RTSP
|
||||
baseArgs.push(
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'ultrafast',
|
||||
'-tune', 'zerolatency',
|
||||
'-profile:v', 'baseline',
|
||||
'-level', '3.1',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-g', '30', // Keyframe interval
|
||||
'-b:v', '1M',
|
||||
'-bufsize', '1M',
|
||||
'-f', 'rtsp',
|
||||
'-rtsp_transport', 'tcp',
|
||||
rtspOutputUrl
|
||||
);
|
||||
|
||||
return baseArgs;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.restartTimeout) {
|
||||
clearTimeout(this.restartTimeout);
|
||||
this.restartTimeout = null;
|
||||
}
|
||||
|
||||
if (this.ffmpegProcess) {
|
||||
this.ffmpegProcess.kill('SIGTERM');
|
||||
|
||||
// Force kill if still running after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (this.ffmpegProcess && !this.ffmpegProcess.killed) {
|
||||
this.ffmpegProcess.kill('SIGKILL');
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
this.ffmpegProcess = null;
|
||||
}
|
||||
|
||||
this.isStreaming = false;
|
||||
this.restartAttempts = 0;
|
||||
this.emit('stopped');
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.isStreaming;
|
||||
}
|
||||
}
|
||||
137
electron/services/MotionDetector.ts
Normal file
137
electron/services/MotionDetector.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
interface MotionDetectorOptions {
|
||||
go2rtcUrl: string;
|
||||
cameraName: string;
|
||||
sensitivityThreshold: number; // % size change to trigger
|
||||
checkIntervalMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Motion detection running in the main process (not throttled by browser).
|
||||
* Uses JPEG file size changes as a proxy for motion - more reliable than byte comparison.
|
||||
* When significant motion occurs, the JPEG compresses differently and size changes.
|
||||
*/
|
||||
export class MotionDetector extends EventEmitter {
|
||||
private options: MotionDetectorOptions;
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
private prevSize: number = 0;
|
||||
private baselineSize: number = 0;
|
||||
private sizeHistory: number[] = [];
|
||||
private isProcessing = false;
|
||||
private noMotionCount = 0;
|
||||
|
||||
constructor(options: MotionDetectorOptions) {
|
||||
super();
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.intervalId) return;
|
||||
console.log(`MotionDetector: Starting detection on ${this.options.cameraName}`);
|
||||
|
||||
this.intervalId = setInterval(() => {
|
||||
this.checkMotion();
|
||||
}, this.options.checkIntervalMs);
|
||||
|
||||
// Do initial checks to establish baseline
|
||||
this.checkMotion();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
this.prevSize = 0;
|
||||
this.baselineSize = 0;
|
||||
this.sizeHistory = [];
|
||||
console.log('MotionDetector: Stopped');
|
||||
}
|
||||
|
||||
private async checkMotion(): Promise<void> {
|
||||
if (this.isProcessing) return;
|
||||
this.isProcessing = true;
|
||||
|
||||
try {
|
||||
const frameData = await this.fetchFrame();
|
||||
if (!frameData) {
|
||||
this.isProcessing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSize = frameData.length;
|
||||
|
||||
// Build up history for baseline (first 5 frames)
|
||||
if (this.sizeHistory.length < 5) {
|
||||
this.sizeHistory.push(currentSize);
|
||||
if (this.sizeHistory.length === 5) {
|
||||
// Calculate baseline as average of first 5 frames
|
||||
this.baselineSize = this.sizeHistory.reduce((a, b) => a + b, 0) / 5;
|
||||
console.log(`MotionDetector: Baseline established at ${this.baselineSize} bytes`);
|
||||
}
|
||||
this.prevSize = currentSize;
|
||||
this.isProcessing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Compare to previous frame AND baseline
|
||||
const prevDiff = Math.abs(currentSize - this.prevSize);
|
||||
const baselineDiff = Math.abs(currentSize - this.baselineSize);
|
||||
|
||||
const prevChangePercent = (prevDiff / this.prevSize) * 100;
|
||||
const baselineChangePercent = (baselineDiff / this.baselineSize) * 100;
|
||||
|
||||
// Motion detected if EITHER:
|
||||
// 1. Frame-to-frame change is significant (something just moved)
|
||||
// 2. Deviation from baseline is significant (scene has changed)
|
||||
const motionDetected = prevChangePercent > this.options.sensitivityThreshold ||
|
||||
baselineChangePercent > (this.options.sensitivityThreshold * 2);
|
||||
|
||||
if (motionDetected) {
|
||||
this.noMotionCount = 0;
|
||||
console.log(`MotionDetector: Motion detected (prev=${prevChangePercent.toFixed(1)}% baseline=${baselineChangePercent.toFixed(1)}%)`);
|
||||
this.emit('motion');
|
||||
} else {
|
||||
this.noMotionCount++;
|
||||
// Update baseline slowly when no motion (adapt to lighting changes)
|
||||
if (this.noMotionCount > 10) {
|
||||
this.baselineSize = this.baselineSize * 0.95 + currentSize * 0.05;
|
||||
}
|
||||
}
|
||||
|
||||
this.prevSize = currentSize;
|
||||
} catch (error) {
|
||||
console.error('MotionDetector: Error checking motion:', error);
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchFrame(): Promise<Buffer | null> {
|
||||
// Try up to 2 times with a short delay
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const url = `${this.options.go2rtcUrl}/api/frame.jpeg?src=${this.options.cameraName}&t=${Date.now()}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
} catch {
|
||||
if (attempt === 0) {
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
170
electron/services/PresenceDetector.ts
Normal file
170
electron/services/PresenceDetector.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// TensorFlow.js imports - will be loaded dynamically
|
||||
let tf: typeof import('@tensorflow/tfjs') | null = null;
|
||||
let cocoSsd: typeof import('@tensorflow-models/coco-ssd') | null = null;
|
||||
|
||||
interface Detection {
|
||||
class: string;
|
||||
score: number;
|
||||
bbox: [number, number, number, number];
|
||||
}
|
||||
|
||||
export class PresenceDetector extends EventEmitter {
|
||||
private model: Awaited<ReturnType<typeof import('@tensorflow-models/coco-ssd').load>> | null = null;
|
||||
private video: HTMLVideoElement | null = null;
|
||||
private canvas: HTMLCanvasElement | null = null;
|
||||
private context: CanvasRenderingContext2D | null = null;
|
||||
private stream: MediaStream | null = null;
|
||||
private detectionInterval: NodeJS.Timeout | null = null;
|
||||
private isRunning: boolean = false;
|
||||
private confidenceThreshold: number = 0.6;
|
||||
private personDetected: boolean = false;
|
||||
private noPersonCount: number = 0;
|
||||
private readonly NO_PERSON_THRESHOLD = 5; // Frames without person before clearing
|
||||
|
||||
constructor(confidenceThreshold: number = 0.6) {
|
||||
super();
|
||||
this.confidenceThreshold = confidenceThreshold;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.isRunning) return;
|
||||
|
||||
try {
|
||||
// Dynamically import TensorFlow.js
|
||||
tf = await import('@tensorflow/tfjs');
|
||||
cocoSsd = await import('@tensorflow-models/coco-ssd');
|
||||
|
||||
// Set backend
|
||||
await tf.setBackend('webgl');
|
||||
await tf.ready();
|
||||
|
||||
// Load COCO-SSD model
|
||||
console.log('Loading COCO-SSD model...');
|
||||
this.model = await cocoSsd.load({
|
||||
base: 'lite_mobilenet_v2', // Faster, lighter model
|
||||
});
|
||||
console.log('Model loaded');
|
||||
|
||||
// Set up video capture
|
||||
await this.setupCamera();
|
||||
|
||||
// Start detection loop
|
||||
this.isRunning = true;
|
||||
this.startDetectionLoop();
|
||||
} catch (error) {
|
||||
console.error('Failed to start presence detection:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async setupCamera(): Promise<void> {
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
width: { ideal: 640 },
|
||||
height: { ideal: 480 },
|
||||
facingMode: 'user',
|
||||
},
|
||||
audio: false,
|
||||
});
|
||||
|
||||
// Create video element
|
||||
this.video = document.createElement('video');
|
||||
this.video.srcObject = this.stream;
|
||||
this.video.autoplay = true;
|
||||
this.video.playsInline = true;
|
||||
|
||||
// Create canvas for processing
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.canvas.width = 640;
|
||||
this.canvas.height = 480;
|
||||
this.context = this.canvas.getContext('2d');
|
||||
|
||||
// Wait for video to be ready
|
||||
await new Promise<void>((resolve) => {
|
||||
if (this.video) {
|
||||
this.video.onloadedmetadata = () => {
|
||||
this.video?.play();
|
||||
resolve();
|
||||
};
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to setup camera:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private startDetectionLoop(): void {
|
||||
// Run detection every 500ms
|
||||
this.detectionInterval = setInterval(async () => {
|
||||
await this.detectPerson();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private async detectPerson(): Promise<void> {
|
||||
if (!this.model || !this.video || !this.context || !this.canvas) return;
|
||||
|
||||
try {
|
||||
// Draw current frame to canvas
|
||||
this.context.drawImage(this.video, 0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Run detection
|
||||
const predictions: Detection[] = await this.model.detect(this.canvas);
|
||||
|
||||
// Check for person with sufficient confidence
|
||||
const personDetection = predictions.find(
|
||||
(p) => p.class === 'person' && p.score >= this.confidenceThreshold
|
||||
);
|
||||
|
||||
if (personDetection) {
|
||||
this.noPersonCount = 0;
|
||||
if (!this.personDetected) {
|
||||
this.personDetected = true;
|
||||
this.emit('personDetected', personDetection);
|
||||
}
|
||||
} else {
|
||||
this.noPersonCount++;
|
||||
if (this.personDetected && this.noPersonCount >= this.NO_PERSON_THRESHOLD) {
|
||||
this.personDetected = false;
|
||||
this.emit('noPersonDetected');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Detection error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.isRunning = false;
|
||||
|
||||
if (this.detectionInterval) {
|
||||
clearInterval(this.detectionInterval);
|
||||
this.detectionInterval = null;
|
||||
}
|
||||
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach((track) => track.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
|
||||
if (this.video) {
|
||||
this.video.srcObject = null;
|
||||
this.video = null;
|
||||
}
|
||||
|
||||
this.canvas = null;
|
||||
this.context = null;
|
||||
this.model = null;
|
||||
}
|
||||
|
||||
isDetecting(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
|
||||
hasPersonPresent(): boolean {
|
||||
return this.personDetected;
|
||||
}
|
||||
}
|
||||
229
electron/services/ScreenManager.ts
Normal file
229
electron/services/ScreenManager.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// DBus environment for GNOME Wayland - needed to communicate with session services
|
||||
const DBUS_ENV = {
|
||||
XDG_RUNTIME_DIR: '/run/user/1000',
|
||||
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
|
||||
};
|
||||
|
||||
export class ScreenManager {
|
||||
private window: BrowserWindow;
|
||||
private idleTimeout: number = 300000; // 5 minutes default
|
||||
private idleTimer: NodeJS.Timeout | null = null;
|
||||
private isScreenOn: boolean = true;
|
||||
private lastActivity: number = Date.now();
|
||||
private screenControlAvailable: boolean = true;
|
||||
private isWayland: boolean = false;
|
||||
|
||||
constructor(window: BrowserWindow) {
|
||||
this.window = window;
|
||||
this.detectDisplayServer();
|
||||
this.setupActivityListeners();
|
||||
this.startIdleMonitor();
|
||||
}
|
||||
|
||||
private detectDisplayServer(): void {
|
||||
// Check if running under Wayland
|
||||
this.isWayland = !!(process.env.WAYLAND_DISPLAY || process.env.XDG_SESSION_TYPE === 'wayland');
|
||||
console.log(`ScreenManager: Display server detected - ${this.isWayland ? 'Wayland' : 'X11'}`);
|
||||
}
|
||||
|
||||
private setupActivityListeners(): void {
|
||||
// Monitor keyboard and mouse activity
|
||||
this.window.webContents.on('before-input-event', () => {
|
||||
this.resetIdleTimer();
|
||||
});
|
||||
|
||||
// Also listen for any cursor/pointer events via IPC from renderer
|
||||
// Touch events on Wayland may not trigger before-input-event
|
||||
}
|
||||
|
||||
// Called from renderer when touch/click detected
|
||||
public handleUserActivity(): void {
|
||||
this.lastActivity = Date.now();
|
||||
// Always try to wake the screen on touch/click - user is actively interacting
|
||||
if (!this.isScreenOn) {
|
||||
this.wakeScreen();
|
||||
}
|
||||
}
|
||||
|
||||
private startIdleMonitor(): void {
|
||||
this.idleTimer = setInterval(() => {
|
||||
const idleTime = Date.now() - this.lastActivity;
|
||||
if (idleTime >= this.idleTimeout && this.isScreenOn && this.screenControlAvailable) {
|
||||
this.sleepScreen();
|
||||
}
|
||||
}, 30000); // Check every 30 seconds (not 10) to reduce spam
|
||||
}
|
||||
|
||||
private resetIdleTimer(): void {
|
||||
this.lastActivity = Date.now();
|
||||
if (!this.isScreenOn) {
|
||||
this.wakeScreen();
|
||||
}
|
||||
}
|
||||
|
||||
setIdleTimeout(timeout: number): void {
|
||||
this.idleTimeout = timeout;
|
||||
}
|
||||
|
||||
async wakeScreen(): Promise<void> {
|
||||
// Always attempt to wake - the screen may have been externally put to sleep by GNOME
|
||||
if (!this.screenControlAvailable) {
|
||||
this.isScreenOn = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.platform === 'linux') {
|
||||
if (this.isWayland) {
|
||||
// On GNOME Wayland, we need multiple approaches because:
|
||||
// 1. The screensaver/lock screen blocks DBus SetActive calls
|
||||
// 2. We need to wake DPMS separately from screensaver
|
||||
// 3. Simulating input is the most reliable way to wake
|
||||
|
||||
let woke = false;
|
||||
|
||||
// Method 1: Simulate mouse movement with ydotool (most reliable on Wayland)
|
||||
// This bypasses all the GNOME screensaver/DPMS complications
|
||||
try {
|
||||
// ydotool syntax: mousemove <x> <y> (relative movement)
|
||||
await execAsync('ydotool mousemove 1 0 && ydotool mousemove -- -1 0', { env: { ...process.env, ...DBUS_ENV } });
|
||||
console.log('ScreenManager: Screen woke via ydotool mouse movement');
|
||||
woke = true;
|
||||
} catch (e) {
|
||||
console.log('ScreenManager: ydotool failed:', e);
|
||||
}
|
||||
|
||||
// Method 2: loginctl unlock-session (unlocks GNOME lock screen)
|
||||
if (!woke) {
|
||||
try {
|
||||
await execAsync('loginctl unlock-session', { env: { ...process.env, ...DBUS_ENV } });
|
||||
console.log('ScreenManager: Session unlocked via loginctl');
|
||||
woke = true;
|
||||
} catch (e) {
|
||||
console.log('ScreenManager: loginctl unlock failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: gnome-screensaver-command (older GNOME)
|
||||
if (!woke) {
|
||||
try {
|
||||
await execAsync('gnome-screensaver-command --deactivate', { env: { ...process.env, ...DBUS_ENV } });
|
||||
console.log('ScreenManager: Screen woke via gnome-screensaver-command');
|
||||
woke = true;
|
||||
} catch {
|
||||
console.log('ScreenManager: gnome-screensaver-command not available');
|
||||
}
|
||||
}
|
||||
|
||||
// Method 4: DBus SetActive (works when screen is blanked but not locked)
|
||||
if (!woke) {
|
||||
try {
|
||||
await execAsync('dbus-send --session --dest=org.gnome.ScreenSaver --type=method_call /org/gnome/ScreenSaver org.gnome.ScreenSaver.SetActive boolean:false', { env: { ...process.env, ...DBUS_ENV } });
|
||||
console.log('ScreenManager: Screen woke via dbus-send');
|
||||
woke = true;
|
||||
} catch {
|
||||
console.log('ScreenManager: dbus-send SetActive failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Method 5: Wake DPMS via wlr-randr or gnome-randr
|
||||
try {
|
||||
await execAsync('gnome-randr modify --on DP-1 2>/dev/null || wlr-randr --output DP-1 --on 2>/dev/null || true', { env: { ...process.env, ...DBUS_ENV } });
|
||||
} catch {
|
||||
// Ignore - display names vary
|
||||
}
|
||||
|
||||
if (!woke) {
|
||||
console.log('ScreenManager: All wake methods failed');
|
||||
}
|
||||
} else {
|
||||
// X11 methods
|
||||
try {
|
||||
await execAsync('xset dpms force on');
|
||||
} catch {
|
||||
try {
|
||||
await execAsync('xdotool key shift');
|
||||
} catch {
|
||||
console.log('ScreenManager: No X11 screen control available, disabling feature');
|
||||
this.screenControlAvailable = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (process.platform === 'win32') {
|
||||
await execAsync(
|
||||
'powershell -Command "(Add-Type -MemberDefinition \'[DllImport(\\"user32.dll\\")]public static extern int SendMessage(int hWnd,int hMsg,int wParam,int lParam);\' -Name a -Pas)::SendMessage(-1,0x0112,0xF170,-1)"'
|
||||
);
|
||||
} else if (process.platform === 'darwin') {
|
||||
await execAsync('caffeinate -u -t 1');
|
||||
}
|
||||
|
||||
this.isScreenOn = true;
|
||||
this.lastActivity = Date.now();
|
||||
this.window.webContents.send('screen:woke');
|
||||
} catch (error) {
|
||||
console.error('Failed to wake screen:', error);
|
||||
this.isScreenOn = true; // Assume screen is on to prevent retry loops
|
||||
}
|
||||
}
|
||||
|
||||
async sleepScreen(): Promise<void> {
|
||||
if (!this.isScreenOn) return;
|
||||
if (!this.screenControlAvailable) return;
|
||||
|
||||
try {
|
||||
if (process.platform === 'linux') {
|
||||
if (this.isWayland) {
|
||||
// Use busctl with DBUS session for GNOME on Wayland
|
||||
try {
|
||||
await execAsync('busctl --user call org.gnome.ScreenSaver /org/gnome/ScreenSaver org.gnome.ScreenSaver SetActive b true', { env: { ...process.env, ...DBUS_ENV } });
|
||||
console.log('ScreenManager: Screen slept via GNOME ScreenSaver DBus');
|
||||
} catch {
|
||||
try {
|
||||
// Fallback: Try wlopm
|
||||
await execAsync('wlopm --off \\*', { env: { ...process.env, ...DBUS_ENV } });
|
||||
} catch {
|
||||
// No working method - disable screen control
|
||||
console.log('ScreenManager: Wayland screen sleep not available, disabling feature');
|
||||
this.screenControlAvailable = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await execAsync('xset dpms force off');
|
||||
} catch {
|
||||
console.log('ScreenManager: X11 screen sleep not available, disabling feature');
|
||||
this.screenControlAvailable = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (process.platform === 'win32') {
|
||||
await execAsync(
|
||||
'powershell -Command "(Add-Type -MemberDefinition \'[DllImport(\\"user32.dll\\")]public static extern int SendMessage(int hWnd,int hMsg,int wParam,int lParam);\' -Name a -Pas)::SendMessage(-1,0x0112,0xF170,2)"'
|
||||
);
|
||||
} else if (process.platform === 'darwin') {
|
||||
await execAsync('pmset displaysleepnow');
|
||||
}
|
||||
|
||||
this.isScreenOn = false;
|
||||
this.window.webContents.send('screen:slept');
|
||||
} catch (error) {
|
||||
console.error('Failed to sleep screen:', error);
|
||||
// Disable feature to prevent spam
|
||||
this.screenControlAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.idleTimer) {
|
||||
clearInterval(this.idleTimer);
|
||||
this.idleTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user