homebridge-eufy-security-mikebcbc
Version:
Control Eufy Security from homebridge.
413 lines • 20.3 kB
JavaScript
"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