UNPKG

homebridge-eufy-security

Version:
345 lines 14.2 kB
import * as fs from 'fs'; import { PropertyName } from 'eufy-security-client'; import { SNAPSHOT_CACHE_BALANCED_SECONDS, SNAPSHOT_CACHE_FRESH_SECONDS, SNAPSHOT_CLOUD_SKIP_MS, SNAPSHOT_FETCH_TIMEOUT_MS, } from '../settings.js'; import { SnapshotHandlingMethod } from '../utils/configTypes.js'; import { FFmpeg, FFmpegParameters } from '../utils/ffmpeg.js'; import { isRtspReady } from '../utils/utils.js'; import { fileURLToPath } from 'url'; import path from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const PLACEHOLDER_PATHS = { offline: path.resolve(__dirname, '../../media/camera-offline.png'), disabled: path.resolve(__dirname, '../../media/camera-disabled.png'), unavailable: path.resolve(__dirname, '../../media/Snapshot-Unavailable.png'), }; /** * possible performance settings: * 1. snapshots as current as possible (weak homebridge performance) * - always get a new image from cloud or cam * 2. balanced * - start snapshot refresh but return snapshot as fast as possible * if request takes too long old snapshot will be returned * 3. get an old snapshot immediately * - wait on cloud snapshot with new events * * extra options: * - force refresh snapshots with interval * - force immediate snapshot-reject when ringing * * Drawbacks: elapsed time in homekit might be wrong */ export class snapshotDelegate { livestreamManager; eufyPath; device; cameraConfig; currentSnapshot; placeholders = new Map(); pendingFetch; isDeviceOffline = false; log; constructor(camera, livestreamManager) { this.livestreamManager = livestreamManager; this.eufyPath = camera.platform.eufyPath; this.device = camera.device; this.cameraConfig = camera.cameraConfig; this.log = camera.log; this.setupEventListeners(); this.logSnapshotHandlingMethod(); this.loadPlaceholderImages(); this.initializeDeviceState(); this.loadInitialSnapshot(); } setupEventListeners() { this.device.on('property changed', this.onPropertyValueChanged.bind(this)); } logSnapshotHandlingMethod() { const method = this.cameraConfig.snapshotHandlingMethod; switch (method) { case SnapshotHandlingMethod.AlwaysFresh: this.log.info('is set to generate new snapshots on events every time. This might reduce homebridge performance and increase power consumption.'); break; case SnapshotHandlingMethod.Balanced: this.log.info('is set to balanced snapshot handling.'); break; case SnapshotHandlingMethod.Auto: case SnapshotHandlingMethod.CloudOnly: this.log.info('is set to handle snapshots with cloud images. Snapshots might be older than they appear.'); break; default: this.log.warn(`Unknown snapshot handling method (${method}), falling back to cloud-only.`); } } loadPlaceholderImages() { for (const [key, path] of Object.entries(PLACEHOLDER_PATHS)) { try { this.placeholders.set(key, fs.readFileSync(path)); } catch (error) { this.log.error(`Could not cache ${key} placeholder for further use: ${error}`); } } } getPlaceholder(key) { const buf = this.placeholders.get(key); if (!buf) { throw new Error(`Placeholder image '${key}' is not available.`); } return buf; } initializeDeviceState() { try { const state = this.device.getPropertyValue(PropertyName.DeviceState); this.isDeviceOffline = (state === 0 || state === 3); if (this.isDeviceOffline) { this.log.info('Device is currently offline (state: ' + state + ').'); } } catch (error) { this.log.debug('Could not read initial device state: ' + error); } } loadInitialSnapshot() { try { const picture = this.device.getPropertyValue(PropertyName.DevicePicture); if (picture && picture.type) { this.storeSnapshotForCache(picture.data, 0); return; } } catch (error) { this.log.debug('Could not fetch snapshot from device property: ' + error); } // Fallback: try to load a previously cached snapshot from disk this.loadSnapshotFromDisk(); } loadSnapshotFromDisk() { const serial = this.device.getSerial(); const extensions = ['jpg', 'png', 'bmp']; for (const ext of extensions) { const filePath = `${this.eufyPath}/${serial}.${ext}`; try { if (fs.existsSync(filePath)) { const data = fs.readFileSync(filePath); if (data.length > 0) { const mtime = fs.statSync(filePath).mtimeMs; this.storeSnapshotForCache(data, mtime); this.log.info(`Loaded cached snapshot from disk: ${filePath}`); return; } } } catch (error) { this.log.debug(`Failed to load snapshot from ${filePath}: ${error}`); } } this.log.warn('No cached snapshot found on disk for device ' + serial); } storeSnapshotForCache(data, time) { this.currentSnapshot = { timestamp: time ??= Date.now(), image: data }; } async getSnapshotBufferResized(request) { return await this.resizeSnapshot(await this.getSnapshotBuffer(), request); } async getSnapshotBuffer() { if (!this.device.isEnabled()) { this.log.debug('Device is disabled, returning disabled snapshot.'); return this.getPlaceholder('disabled'); } if (this.isDeviceOffline) { this.log.debug('Device is offline, returning offline snapshot.'); return this.getPlaceholder('offline'); } if (this.isCacheFresh(SNAPSHOT_CACHE_FRESH_SECONDS)) { return this.currentSnapshot.image; } return this.resolveByHandlingMethod(); } async resolveByHandlingMethod() { try { switch (this.cameraConfig.snapshotHandlingMethod) { case SnapshotHandlingMethod.AlwaysFresh: return await this.fetchSnapshotFromStream(); case SnapshotHandlingMethod.Balanced: if (this.isCacheFresh(SNAPSHOT_CACHE_BALANCED_SECONDS)) { return this.currentSnapshot.image; } return await this.fetchSnapshotFromStream(); case SnapshotHandlingMethod.Auto: case SnapshotHandlingMethod.CloudOnly: default: return this.getCachedOrPlaceholder(); } } catch (err) { this.log.warn('Snapshot retrieval failed, falling back to cache:', err); return this.getCachedOrPlaceholder(); } } /** * Fetches a fresh snapshot from a live stream. */ async fetchSnapshotFromStream() { this.log.info('Begin live streaming to access the most recent snapshot (significant battery drain on the device)'); await this.fetchCurrentCameraSnapshot(); if (this.currentSnapshot) { return this.currentSnapshot.image; } throw new Error('Snapshot fetch completed but no snapshot stored'); } /** * Returns cached snapshot or the unavailable placeholder. */ getCachedOrPlaceholder() { if (this.currentSnapshot) { return this.currentSnapshot.image; } this.log.warn('No currentSnapshot available, using fallback unavailable snapshot image'); return this.getPlaceholder('unavailable'); } isCacheFresh(maxAgeSeconds) { return !!this.currentSnapshot && (Date.now() - this.currentSnapshot.timestamp) / 1000 <= maxAgeSeconds; } storeImage(file, image) { const filePath = `${this.eufyPath}/${file}`; try { fs.writeFileSync(filePath, image); this.log.debug(`Stored Image: ${filePath}`); } catch (error) { this.log.warn(`Failed to store image: ${filePath} - ${error}`); } } async onPropertyValueChanged(device, name) { switch (name) { case 'picture': { const picture = device.getPropertyValue(PropertyName.DevicePicture); if (picture && picture.type) { this.storeImage(`${device.getSerial()}.${picture.type.ext}`, picture.data); if (this.currentSnapshot && (Date.now() - this.currentSnapshot.timestamp) < SNAPSHOT_CLOUD_SKIP_MS) { this.log.debug('Skipping cloud snapshot update, a recent stream snapshot already exists.'); } else { this.storeSnapshotForCache(picture.data); } } break; } case 'enabled': { const enabled = device.getPropertyValue(PropertyName.DeviceEnabled); this.log.info(`Device enabled state changed to: ${enabled}`); if (enabled) { this.currentSnapshot = undefined; } break; } case 'state': { const state = device.getPropertyValue(PropertyName.DeviceState); const wasOffline = this.isDeviceOffline; this.isDeviceOffline = (state === 0 || state === 3); if (this.isDeviceOffline && !wasOffline) { this.log.warn(`Device went offline (state: ${state}).`); } else if (!this.isDeviceOffline && wasOffline) { this.log.info(`Device came back online (state: ${state}).`); this.currentSnapshot = undefined; } break; } } } /** * Fetches a snapshot from the camera, stores it in cache, and deduplicates concurrent calls. */ async fetchCurrentCameraSnapshot() { if (this.pendingFetch) { return this.pendingFetch; } this.log.debug('Fetching new snapshot from camera.'); this.pendingFetch = this.withTimeout((async () => { const source = await this.getCameraSource(); const isLocalStream = source.type === 'local'; try { const buffer = await this.runFFmpegSnapshot('[Snapshot Process]', async (params) => { if (source.type === 'rtsp') { params.setInputSource(source.url); } else { await params.setInputStream(source.stream); } if (this.cameraConfig.delayCameraSnapshot) { params.setDelayedSnapshot(); } }); this.storeSnapshotForCache(buffer); } finally { if (isLocalStream) { this.livestreamManager.stopLocalLiveStream(); } } })(), SNAPSHOT_FETCH_TIMEOUT_MS).finally(() => { this.pendingFetch = undefined; }); return this.pendingFetch; } async getCameraSource() { if (isRtspReady(this.device, this.cameraConfig)) { const url = this.device.getPropertyValue(PropertyName.DeviceRTSPStreamUrl); this.log.debug('RTSP URL: ' + url); return { type: 'rtsp', url }; } const streamData = await this.livestreamManager.getLocalLiveStream(); return { type: 'local', stream: streamData.videostream }; } withTimeout(promise, ms) { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`Snapshot fetch timed out after ${ms}ms`)), ms); promise.then(resolve, reject).finally(() => clearTimeout(timer)); }); } /** * Captures a single frame from an already-running livestream and stores it * as the latest snapshot. Does NOT stop the livestream — safe to call while * a HomeKit live view is active. */ async captureSnapshotFromActiveLivestream() { if (this.pendingFetch) { this.log.debug('Snapshot fetch already in progress, skipping livestream capture.'); return; } try { const source = await this.getCameraSource(); const buffer = await this.runFFmpegSnapshot('[Livestream Snapshot]', async (params) => { if (source.type === 'rtsp') { params.setInputSource(source.url); } else { await params.setInputStream(source.stream); } if (this.cameraConfig.delayCameraSnapshot) { params.setDelayedSnapshot(); } }); this.storeSnapshotForCache(buffer); this.storeImage(`${this.device.getSerial()}.jpg`, buffer); this.log.info('Snapshot captured from active livestream.'); } catch (error) { this.log.debug('Failed to capture snapshot from active livestream: ' + error); } } async resizeSnapshot(snapshot, request) { return this.runFFmpegSnapshot('[Snapshot Resize]', (params) => { params.setup(this.cameraConfig, request); }, snapshot); } async runFFmpegSnapshot(label, configure, input) { const params = await FFmpegParameters.forSnapshot(this.cameraConfig.videoConfig?.debug); await configure(params); return new FFmpeg(label, params).getResult(input); } } //# sourceMappingURL=snapshotDelegate.js.map