UNPKG

homebridge-eufy-security-mikebcbc

Version:
413 lines 20.3 kB
"use strict"; /* eslint-disable @typescript-eslint/no-explicit-any */ 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 node_fs_1 = require("node:fs"); const node_stream_1 = require("node:stream"); const eufy_security_client_1 = require("eufy-security-client"); const utils_1 = require("../utils/utils"); const ffmpeg_1 = require("../utils/ffmpeg"); const sharp_1 = __importDefault(require("sharp")); const ffmpeg_params_1 = require("../utils/ffmpeg-params"); const EXTENDED_WAIT_MS = 15000; const SNAPSHOT_WAIT_THRESHOLD_SECONDS = 30; const MILLISECONDS_PER_MINUTE = 60 * 1000; const SnapshotBlack = (0, node_fs_1.readFileSync)(require.resolve('../../media/Snapshot-black.png')); const SnapshotUnavailable = (0, node_fs_1.readFileSync)(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) -> forceRefreshSnapshot * - 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 -> !forceRefreshSnapshot * - 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 node_stream_1.EventEmitter { // eslint-disable-next-line max-len constructor(streamingDelegate) { var _a; super(); this.streamingDelegate = streamingDelegate; this.platform = this.streamingDelegate.platform; this.device = this.streamingDelegate.device; this.accessory = this.streamingDelegate.camera; this.cameraConfig = this.streamingDelegate.cameraConfig; this.cameraName = this.device.getName(); this.log = this.platform.log; this.livestreamManager = this.streamingDelegate.localLivestreamManager; this.refreshProcessRunning = false; this.refreshSnapshotIntervalMinutes = 0; this.lastEvent = 0; this.lastRingEvent = 0; this.lastImageEvent = 0; this.refreshSnapshotIntervalMinutes = (_a = this.cameraConfig.refreshSnapshotIntervalMinutes) !== null && _a !== void 0 ? _a : 0; this.device.on('property changed', this.onPropertyValueChanged.bind(this)); this.device.on('rings', (device, state) => this.onRingEvent(device, state)); this.accessory.eventTypesToHandle.forEach(eventType => { // eslint-disable-next-line @typescript-eslint/no-explicit-any this.device.on(eventType, (device, state) => this.onMotionEvent(device, state)); }); if (this.refreshSnapshotIntervalMinutes) { if (this.refreshSnapshotIntervalMinutes < 5) { this.log.warn(this.cameraName, 'The interval to automatically refresh snapshots is set too low. Minimum is one minute.'); this.refreshSnapshotIntervalMinutes = 5; } // eslint-disable-next-line max-len this.log.info(this.cameraName, 'Setting up automatic snapshot refresh every ' + this.refreshSnapshotIntervalMinutes + ' minutes. This may decrease battery life dramatically. The refresh process for ' + this.cameraName + ' should begin in ' + MINUTES_TO_WAIT_FOR_AUTOMATIC_REFRESH_TO_BEGIN + ' minutes.'); setTimeout(() => { this.automaticSnapshotRefresh(); }, MINUTES_TO_WAIT_FOR_AUTOMATIC_REFRESH_TO_BEGIN * MILLISECONDS_PER_MINUTE); MINUTES_TO_WAIT_FOR_AUTOMATIC_REFRESH_TO_BEGIN++; } if (this.cameraConfig.snapshotHandlingMethod === 1) { // eslint-disable-next-line max-len this.log.info(this.cameraName, 'is set to generate new snapshots on events every time. This might reduce homebridge performance and increase power consumption.'); if (this.refreshSnapshotIntervalMinutes) { // eslint-disable-next-line max-len this.log.warn(this.cameraName, '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(this.cameraName, 'is set to balanced snapshot handling.'); } else if (this.cameraConfig.snapshotHandlingMethod === 3) { this.log.info(this.cameraName, 'is set to handle snapshots with cloud images. Snapshots might be older than they appear.'); } else { this.log.warn(this.cameraName, 'unknown snapshot handling method. SNapshots will not be generated.'); } try { if (this.cameraConfig.immediateRingNotificationWithoutSnapshot) { this.log.info(this.cameraName, 'Empty snapshot will be sent on ring events immediately to speed up homekit notifications.'); } } catch (err) { this.log.error(this.cameraName, 'could not cache black snapshot file for further use: ' + err); } this.fetchSnapshotFromCloud() // get current cloud snapshot for balanced mode scenarios -> first snapshot can be resolved .catch(err => this.log.warn(this.cameraName, 'snapshot handler is initialized without cloud snapshot. Maybe no snapshot will displayed the first times.')); } onRingEvent(device, state) { if (state) { this.log.debug(this.cameraName, 'Snapshot handler detected ring event.'); this.lastRingEvent = Date.now(); } } async onMotionEvent(device, state) { if (state) { this.log.debug(this.cameraName, 'Snapshot handler detected event.'); this.lastEvent = Date.now(); } } async onPropertyValueChanged(device, name, value) { if (name === 'picture') { this.lastImageEvent = Date.now(); this.log.debug(this.cameraName, 'New picture event'); this.fetchSnapshotFromCloud(); } } async getSnapshotBuffer(request) { const now = Date.now(); let snapshot = SnapshotUnavailable; // Fetch snapshot based on handling method try { // Return a recent snapshot if available if (this.currentSnapshot && Math.abs((now - this.currentSnapshot.timestamp) / 1000) <= 15) { this.log.debug('Returning recent cached snapshot.'); snapshot = this.currentSnapshot.image; } else if (this.cameraConfig.immediateRingNotificationWithoutSnapshot && (now - this.lastRingEvent) / 1000 < 5) { this.log.debug('Sending black snapshot to prioritize ring notification.'); snapshot = SnapshotBlack; } else { switch (this.cameraConfig.snapshotHandlingMethod) { case 1: this.log.debug('Fetching the newest snapshot buffer.'); snapshot = await this.getNewestSnapshotBuffer(); break; case 2: this.log.debug('Fetching a balanced snapshot.'); snapshot = await this.getBalancedSnapshot(); break; case 3: this.log.debug('Fetching the newest cloud snapshot.'); snapshot = await this.getNewestCloudSnapshot(); break; default: this.log.debug('No suitable snapshot handling method defined.'); return Promise.reject('No suitable handling method for snapshots defined'); } } } catch (err) { this.log.error('Error fetching snapshot:', err); snapshot = SnapshotUnavailable; } return this.resizeSnapshot(snapshot, request); } /** * Attempts to resolve or reject the promise based on the current snapshot state. * @param resolve - The resolve function of the Promise. * @param reject - The reject function of the Promise. */ handleSnapshotResolution(resolve, reject) { if (this.currentSnapshot) { resolve(this.currentSnapshot.image); } else { reject('No snapshot in memory'); } } /** * Sets up a timeout for snapshot retrieval and handles the resolution or rejection. * @param resolve - The resolve function of the Promise. * @param reject - The reject function of the Promise. * @param timeout - The timeout duration in milliseconds. */ setupSnapshotTimeout(resolve, reject, timeout) { return setTimeout(() => { this.handleSnapshotResolution(resolve, reject); }, timeout); } /** * Sets up an event listener for the 'new snapshot' event. * Clears the provided timeout and handles the snapshot resolution. * @param resolve - The resolve function of the Promise. * @param reject - The reject function of the Promise. * @param snapshotTimeout - The timeout object to clear. */ setupNewSnapshotListener(resolve, reject, snapshotTimeout) { this.once('new snapshot', () => { clearTimeout(snapshotTimeout); this.handleSnapshotResolution(resolve, reject); }); } /** * Asynchronously retrieves the newest snapshot buffer from the camera. * Initiates a snapshot fetch request and waits for the snapshot to be available, * with a timeout to handle cases where the snapshot is not received promptly. * @returns {Promise<Buffer>} A promise that resolves with the latest snapshot image buffer * or rejects with an error message if the snapshot is not * retrieved within the expected time frame. */ async getNewestSnapshotBuffer() { return new Promise((resolve, reject) => { this.fetchCurrentCameraSnapshot().catch((err) => reject(err)); const requestTimeout = this.setupSnapshotTimeout(resolve, reject, EXTENDED_WAIT_MS); this.setupNewSnapshotListener(resolve, reject, requestTimeout); }); } /** * Attempts to retrieve the newest snapshot from the cloud. If a new snapshot is not available * within a specified timeout, it either resolves with the current snapshot or rejects. * @returns {Promise<Buffer>} A promise that resolves with the newest snapshot. */ async getNewestCloudSnapshot() { return new Promise((resolve, reject) => { const newestEvent = Math.max(this.lastRingEvent, this.lastEvent); const diffInSeconds = (Date.now() - newestEvent) / 1000; const snapshotTimeout = diffInSeconds < SNAPSHOT_WAIT_THRESHOLD_SECONDS ? this.setupSnapshotTimeout(resolve, reject, SNAPSHOT_WAIT_THRESHOLD_SECONDS * 1000) : this.setupSnapshotTimeout(resolve, reject, 0); this.setupNewSnapshotListener(resolve, reject, snapshotTimeout); }); } /** * Retrieves a balanced snapshot by considering battery impact and data freshness. * This function attempts to fetch the newest snapshot, either from the camera or the cloud, * depending on the situation and timeout thresholds. * @returns {Promise<Buffer>} A promise that resolves with the balanced snapshot image buffer * or rejects with an error message if the snapshot is not * retrieved within the expected time frame. */ async getBalancedSnapshot() { return new Promise((resolve, reject) => { const newestEvent = Math.max(this.lastRingEvent, this.lastEvent); const diffInSeconds = (Date.now() - newestEvent) / 1000; // Check if the difference between events is less than the threshold if (diffInSeconds < SNAPSHOT_WAIT_THRESHOLD_SECONDS) { // If within the threshold, prioritize camera snapshot this.fetchCurrentCameraSnapshot().catch((err) => reject(err)); } const snapshotTimeout = this.setupSnapshotTimeout(resolve, reject, 200); this.setupNewSnapshotListener(resolve, reject, snapshotTimeout); }); } /** * Triggers an automatic refresh of the camera snapshot at intervals defined in the camera configuration. * If an error occurs during snapshot fetching, it logs a warning. */ automaticSnapshotRefresh() { this.log.debug(this.cameraName, 'Automatic snapshot refresh triggered.'); this.fetchCurrentCameraSnapshot().catch((err) => { // Enhanced error logging this.log.warn(this.cameraName, 'Snapshot fetch error:', err); }); // Clear existing timer if it exists if (this.snapshotRefreshTimer) { clearTimeout(this.snapshotRefreshTimer); } // Schedule the next snapshot refresh, if configured const refreshInterval = this.refreshSnapshotIntervalMinutes; if (refreshInterval) { this.snapshotRefreshTimer = setTimeout(() => { this.automaticSnapshotRefresh(); }, refreshInterval * MILLISECONDS_PER_MINUTE); } } /** * Asynchronously retrieves a snapshot from the cloud. * If no previous snapshots are available, it sets the last and current snapshots to the fetched image. * @returns Promise<void> */ async fetchSnapshotFromCloud() { try { const image = this.device.getPropertyValue(eufy_security_client_1.PropertyName.DevicePicture); this.log.debug(this.cameraName, 'trying to download latest cloud snapshot for future use'); if (!this.lastCloudSnapshot && !this.currentSnapshot) { this.lastCloudSnapshot = { timestamp: Date.now() - 60 * MILLISECONDS_PER_MINUTE, // An hour earlier image: image.data, }; this.currentSnapshot = this.lastCloudSnapshot; this.log.debug(this.cameraName, 'Stored cloud snapshot for future use.'); this.emit('new snapshot'); } return Promise.resolve(); } catch (err) { this.log.warn(this.cameraName, 'Couldt not get cloud snapshot: ' + err); return Promise.reject(err); } } /** * Fetches the current camera snapshot and updates the current snapshot state. * This function will exit early if a refresh process is already running. * @returns Promise<void> */ async fetchCurrentCameraSnapshot() { if (this.refreshProcessRunning) { return; } this.refreshProcessRunning = true; this.log.debug(`${this.cameraName} Locked refresh process.`); this.log.debug(`${this.cameraName} Fetching new snapshot from camera.`); try { const snapshotBuffer = await this.getCurrentCameraSnapshot(); this.log.debug(`${this.cameraName} Store new snapshot from camera in memory for future use.`); this.currentSnapshot = { timestamp: Date.now(), image: snapshotBuffer, }; this.emit('new snapshot'); } catch (err) { this.log.warn(`${this.cameraName} Error fetching snapshot: ${err}`); throw err; } finally { this.refreshProcessRunning = false; this.log.debug(`${this.cameraName} Unlocked refresh process.`); } } /** * Retrieves the camera source for capturing snapshots. * * This method determines the appropriate camera source based on the camera configuration * and device capabilities. It supports fetching the RTSP stream URL directly from the device * if the RTSP service is ready. Otherwise, it attempts to fetch a local live stream source. * * @returns {Promise<StreamSource>} A promise that resolves to the camera source object. * @throws Throws an error if the camera source cannot be determined or retrieved. */ async getCameraSource() { if ((0, utils_1.is_rtsp_ready)(this.device, this.cameraConfig, this.log)) { const url = this.device.getPropertyValue(eufy_security_client_1.PropertyName.DeviceRTSPStreamUrl); this.log.debug(`${this.cameraName} RTSP URL: ${url}`); return { url: url }; } else { const streamData = await this.livestreamManager.getLocalLivestream(); return { stream: streamData.videostream, livestreamId: 1 }; } } /** * Captures a snapshot from the current camera source. * * This method first retrieves the current camera source. Depending on the source type (URL or stream), * it configures the FFmpeg parameters accordingly. If a delay is configured for the camera snapshot, * it sets up the delayed snapshot parameter. The method then uses FFmpeg to capture the snapshot and * returns the result as a buffer. If capturing from a live stream, it ensures to stop the live stream * after capturing the snapshot or in case of an error. * * @returns {Promise<Buffer>} A promise that resolves to the snapshot captured as a buffer. * @throws Throws an error if no valid camera source is detected or if the snapshot capturing process fails. */ async getCurrentCameraSnapshot() { var _a; const source = await this.getCameraSource(); const parameters = await ffmpeg_params_1.FFmpegParameters.create({ type: 'snapshot', debug: (_a = this.cameraConfig.videoConfig) === null || _a === void 0 ? void 0 : _a.debug }); if (source.url) { parameters.setInputSource(source.url); } else if (source.stream && source.livestreamId) { await parameters.setInputStream(source.stream); } else { throw new Error('No valid camera source detected.'); } if (this.cameraConfig.delayCameraSnapshot) { parameters.setDelayedSnapshot(); } try { const ffmpeg = new ffmpeg_1.FFmpeg(`[${this.cameraName}] [Snapshot Process]`, [parameters], this.platform.ffmpegLogger); return await ffmpeg.getResult(); } finally { if (source.livestreamId) { this.log.debug('STOP! Snapshot'); this.livestreamManager.stopLocalLiveStream(); } } } /** * Resizes a given snapshot image buffer to the specified dimensions. * * This function utilizes the Sharp library to resize an image buffer. The image is resized * to the width and height specified in the SnapshotRequest object. Sharp provides a more * efficient and Node.js-native way of handling image processing compared to FFmpeg. * * @param {Buffer} snapshot - The image buffer to be resized. * @param {SnapshotRequest} request - The object containing the desired dimensions. * @returns {Promise<Buffer>} A Promise that resolves to the resized image buffer. * @throws Will throw an error if the resizing process fails. */ async resizeSnapshot(snapshot, request) { try { // Using Sharp to resize the image const resizedImage = await (0, sharp_1.default)(snapshot) .resize(request.width, request.height) .toBuffer(); return resizedImage; } catch (error) { this.log.error('Error resizing snapshot:', error); throw error; } } } exports.SnapshotManager = SnapshotManager; //# sourceMappingURL=SnapshotManager.js.map