Initial commit: Electron + React touchscreen kiosk dashboard for Home Assistant
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user