Initial commit: Electron + React touchscreen kiosk dashboard for Home Assistant

This commit is contained in:
root
2026-02-25 23:01:20 -06:00
commit 97a7912eae
84 changed files with 12059 additions and 0 deletions

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}
}