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