UNPKG

homebridge-eufy-security

Version:
417 lines 17.5 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SnapshotManager = void 0; const stream_1 = require("stream"); const eufy_security_client_1 = require("eufy-security-client"); const ffmpeg_for_homebridge_1 = __importDefault(require("ffmpeg-for-homebridge")); const utils_1 = require("../utils/utils"); const ffmpeg_1 = require("../utils/ffmpeg"); const fs = __importStar(require("fs")); const CameraOffline = require.resolve('../../media/camera-offline.png'); const CameraDisabled = require.resolve('../../media/camera-disabled.png'); const SnapshotBlackPath = require.resolve('../../media/Snapshot-black.png'); const SnapshotUnavailable = require.resolve('../../media/Snapshot-Unavailable.png'); let MINUTES_TO_WAIT_FOR_AUTOMATIC_REFRESH_TO_BEGIN = 1; // should be incremented by 1 for every device /** * 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 */ class SnapshotManager extends stream_1.EventEmitter { livestreamManager; platform; device; cameraConfig; videoProcessor = ffmpeg_for_homebridge_1.default || 'ffmpeg'; currentSnapshot; blackSnapshot; cameraOffline; cameraDisabled; unavailableSnapshot; refreshProcessRunning = false; lastEvent = 0; lastRingEvent = 0; log; snapshotRefreshTimer; constructor(camera, livestreamManager) { super(); this.livestreamManager = livestreamManager; this.platform = camera.platform; this.device = camera.device; this.cameraConfig = camera.cameraConfig; this.log = camera.log; this.device.on('property changed', this.onPropertyValueChanged.bind(this)); const eventTypes = [ 'motion detected', 'person detected', 'pet detected', 'sound detected', 'crying detected', 'vehicle detected', 'dog detected', 'dog lick detected', 'dog poop detected', 'stranger person detected', ]; eventTypes.forEach(eventType => { this.device.on(eventType, this.onEvent.bind(this)); }); this.device.on('rings', this.onRingEvent.bind(this)); if (this.cameraConfig.refreshSnapshotIntervalMinutes) { if (this.cameraConfig.refreshSnapshotIntervalMinutes < 5) { this.log.warn('The interval to automatically refresh snapshots is set too low. Minimum is one minute.'); this.cameraConfig.refreshSnapshotIntervalMinutes = 5; } this.log.info('Setting up automatic snapshot refresh every ' + this.cameraConfig.refreshSnapshotIntervalMinutes + ' minutes. This may decrease battery life dramatically. The refresh process should begin in ' + MINUTES_TO_WAIT_FOR_AUTOMATIC_REFRESH_TO_BEGIN + ' minutes.'); setTimeout(() => { this.automaticSnapshotRefresh(); }, MINUTES_TO_WAIT_FOR_AUTOMATIC_REFRESH_TO_BEGIN * 60 * 1000); MINUTES_TO_WAIT_FOR_AUTOMATIC_REFRESH_TO_BEGIN++; } if (this.cameraConfig.snapshotHandlingMethod === 1) { this.log.info('is set to generate new snapshots on events every time. This might reduce homebridge performance and increase power consumption.'); if (this.cameraConfig.refreshSnapshotIntervalMinutes) { this.log.warn('You have enabled automatic snapshot refreshing. It is recommened not to use this setting with forced snapshot refreshing.'); } } else if (this.cameraConfig.snapshotHandlingMethod === 2) { this.log.info('is set to balanced snapshot handling.'); } else if (this.cameraConfig.snapshotHandlingMethod === 3) { this.log.info('is set to handle snapshots with cloud images. Snapshots might be older than they appear.'); } else { this.log.warn('unknown snapshot handling method. SNapshots will not be generated.'); } try { this.blackSnapshot = fs.readFileSync(SnapshotBlackPath); if (this.cameraConfig.immediateRingNotificationWithoutSnapshot) { this.log.info('Empty snapshot will be sent on ring events immediately to speed up homekit notifications.'); } } catch (error) { this.log.error('could not cache black snapshot file for further use: ' + error); } try { this.unavailableSnapshot = fs.readFileSync(SnapshotUnavailable); } catch (error) { this.log.error('could not cache SnapshotUnavailable file for further use: ' + error); } try { this.cameraOffline = fs.readFileSync(CameraOffline); } catch (error) { this.log.error('could not cache CameraOffline file for further use: ' + error); } try { this.cameraDisabled = fs.readFileSync(CameraDisabled); } catch (error) { this.log.error('could not cache CameraDisabled file for further use: ' + error); } try { const picture = this.device.getPropertyValue(eufy_security_client_1.PropertyName.DevicePicture); if (picture && picture.type) { this.storeSnapshotForCache(picture.data, 0); } else { throw new Error('No currentSnapshot'); } } catch (error) { this.log.error('could not fetch old snapshot: ' + error); } } onRingEvent(device, state) { if (state) { this.log.debug('Snapshot handler detected ring event.'); this.lastRingEvent = Date.now(); } } onEvent(device, state) { if (state) { this.log.debug('Snapshot handler detected event.'); this.lastEvent = Date.now(); } } storeSnapshotForCache(data, time) { this.currentSnapshot = { timestamp: time ??= Date.now(), image: data }; } async getSnapshotBufferResized(request) { return await this.resizeSnapshot(await this.getSnapshotBuffer(), request); } async getSnapshotBuffer() { // return a new snapshot if it is recent enough (not more than 15 seconds) if (this.currentSnapshot) { const diff = Math.abs((Date.now() - this.currentSnapshot.timestamp) / 1000); if (diff <= 15) { return this.currentSnapshot.image; } } // It should never happend since camera is disabled in HK but in case of... if (!this.device.isEnabled()) { if (this.cameraDisabled) { return this.cameraDisabled; } else { return Promise.reject('Something wrong with file systems. Looks likes not enought rights!'); } } const diff = (Date.now() - this.lastRingEvent) / 1000; if (this.cameraConfig.immediateRingNotificationWithoutSnapshot && diff < 5) { this.log.debug('Sending empty snapshot to speed up homekit notification for ring event.'); if (this.blackSnapshot) { return this.blackSnapshot; } else { return Promise.reject('Prioritize ring notification over snapshot request. But could not supply empty snapshot.'); } } let snapshot = Buffer.from([]); try { if (this.cameraConfig.snapshotHandlingMethod === 1) { // return a preferablly most recent snapshot every time snapshot = await this.getSnapshotFromStream(); } else if (this.cameraConfig.snapshotHandlingMethod === 2) { // balanced method snapshot = await this.getBalancedSnapshot(); } else if (this.cameraConfig.snapshotHandlingMethod === 3) { // fastest method with potentially old snapshots snapshot = await this.getSnapshotFromCache(); } else { return Promise.reject('No suitable handling method for snapshots defined'); } return snapshot; } catch { try { return this.getSnapshotFromCache(); } catch (error) { this.log.error(error); if (this.unavailableSnapshot) { return this.unavailableSnapshot; } else { throw (error); } } } } /** * Retrieves the newest snapshot buffer asynchronously. * @returns A Promise resolving to a Buffer containing the newest snapshot image. */ getSnapshotFromStream() { this.log.info(`Begin live streaming to access the most recent snapshot (significant battery drain on the device)`); return new Promise((resolve, reject) => { // Set a timeout for the snapshot request const requestTimeout = setTimeout(() => { reject('getSnapshotFromStream timed out'); }, 4 * 1000); // Define a listener for the 'new snapshot' event const snapshotListener = () => { clearTimeout(requestTimeout); // Clear the timeout if the snapshot is received if (this.currentSnapshot) { resolve(this.currentSnapshot.image); // Resolve the promise with the snapshot image } else { reject('getSnapshotFromStream error'); // Reject if there's an issue with the snapshot } }; // Fetch the current camera snapshot and attach the 'new snapshot' listener this.fetchCurrentCameraSnapshot() .then(() => { this.once('new snapshot', snapshotListener); // Listen for the 'new snapshot' event }) .catch((error) => { clearTimeout(requestTimeout); // Clear the timeout if an error occurs during fetching reject(error); // Reject the promise with the error }); }); } /** * Retrieves the newest cloud snapshot's image data. * @returns Buffer The image data as a Buffer. * @throws Error if there's no currentSnapshot available. */ getSnapshotFromCache() { // Check if there's a currentSnapshot available if (this.currentSnapshot) { // If available, return the image data return this.currentSnapshot.image; } else { // If not available, throw an error throw new Error('No currentSnapshot available'); } } async getBalancedSnapshot() { if (this.currentSnapshot) { const diff = Math.abs((Date.now() - this.currentSnapshot.timestamp) / 1000); if (diff <= 30) { return this.currentSnapshot.image; } } return this.getSnapshotFromStream(); } automaticSnapshotRefresh() { this.log.debug('Automatic snapshot refresh triggered.'); this.fetchCurrentCameraSnapshot().catch((error) => this.log.warn(error)); if (this.snapshotRefreshTimer) { clearTimeout(this.snapshotRefreshTimer); } if (this.cameraConfig.refreshSnapshotIntervalMinutes) { this.snapshotRefreshTimer = setTimeout(() => { this.automaticSnapshotRefresh(); }, this.cameraConfig.refreshSnapshotIntervalMinutes * 60 * 1000); } } storeImage(file, image) { const filePath = `${this.platform.eufyPath}/${file}`; try { fs.writeFileSync(filePath, image); this.log.debug(`Stored Image: ${filePath}`); } catch (error) { this.log.debug(`Error: ${filePath} - ${error}`); } } async onPropertyValueChanged(device, name) { if (name === 'picture') { const picture = device.getPropertyValue(eufy_security_client_1.PropertyName.DevicePicture); if (picture && picture.type) { this.storeImage(`${device.getSerial()}.${picture.type.ext}`, picture.data); this.storeSnapshotForCache(picture.data); this.emit('new snapshot'); } } } async fetchCurrentCameraSnapshot() { if (this.refreshProcessRunning) { return Promise.resolve(); } this.refreshProcessRunning = true; this.log.debug('Locked refresh process.'); this.log.debug('Fetching new snapshot from camera.'); try { const snapshotBuffer = await this.getCurrentCameraSnapshot(); this.refreshProcessRunning = false; this.log.debug('Unlocked refresh process.'); this.log.debug('store new snapshot from camera in memory. Using this for future use.'); this.storeSnapshotForCache(snapshotBuffer); this.emit('new snapshot'); return Promise.resolve(); } catch (error) { this.refreshProcessRunning = false; this.log.debug('Unlocked refresh process.'); return Promise.reject(error); } } async getCurrentCameraSnapshot() { const source = await this.getCameraSource(); if (!source) { return Promise.reject('No camera source detected.'); } const parameters = await ffmpeg_1.FFmpegParameters.forSnapshot(this.cameraConfig.videoConfig?.debug); if (source.url) { parameters.setInputSource(source.url); } else if (source.stream) { await parameters.setInputStream(source.stream); } else { return Promise.reject('No valid camera source detected.'); } if (this.cameraConfig.delayCameraSnapshot) { parameters.setDelayedSnapshot(); } try { const ffmpeg = new ffmpeg_1.FFmpeg(`[Snapshot Process]`, parameters); const buffer = await ffmpeg.getResult(); this.livestreamManager.stopLocalLiveStream(); return Promise.resolve(buffer); } catch (error) { this.livestreamManager.stopLocalLiveStream(); return Promise.reject(error); } } async getCameraSource() { if ((0, utils_1.is_rtsp_ready)(this.device, this.cameraConfig)) { try { const url = this.device.getPropertyValue(eufy_security_client_1.PropertyName.DeviceRTSPStreamUrl); this.log.debug('RTSP URL: ' + url); return { url: url, }; } catch (error) { this.log.warn('Could not get snapshot from rtsp stream!', error); return null; } } else { try { const streamData = await this.livestreamManager.getLocalLivestream(); return { stream: streamData.videostream, }; } catch (error) { this.log.warn('Could not get snapshot from livestream!', error); return null; } } } async resizeSnapshot(snapshot, request) { const parameters = await ffmpeg_1.FFmpegParameters.forSnapshot(this.cameraConfig.videoConfig?.debug); parameters.setup(this.cameraConfig, request); const ffmpeg = new ffmpeg_1.FFmpeg(`[Snapshot Resize Process]`, parameters); return ffmpeg.getResult(snapshot); } } exports.SnapshotManager = SnapshotManager; //# sourceMappingURL=SnapshotManager.js.map