homebridge-eufy-security
Version:
Control Eufy Security from homebridge.
461 lines • 21.1 kB
JavaScript
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 });
const eufy_security_client_1 = require("eufy-security-client");
const plugin_ui_utils_1 = require("@homebridge/plugin-ui-utils");
const fs = __importStar(require("fs"));
const tslog_1 = require("tslog");
const rotating_file_stream_1 = require("rotating-file-stream");
const zip_lib_1 = require("zip-lib");
const types_1 = require("./configui/app/util/types");
const package_json_1 = require("../package.json");
const node_process_1 = require("node:process");
const semver_1 = require("semver");
const path_1 = __importDefault(require("path"));
class UiServer extends plugin_ui_utils_1.HomebridgePluginUiServer {
stations = [];
eufyClient = null;
log;
tsLog;
storagePath = this.homebridgeStoragePath + '/eufysecurity';
storedAccessories_file = this.storagePath + '/accessories.json';
logZipFilePath = this.storagePath + '/logs.zip';
adminAccountUsed = false;
config = {
username: '',
password: '',
language: 'en',
country: 'US',
trustedDeviceName: 'My Phone',
persistentDir: this.storagePath,
p2pConnectionSetup: 0,
pollingIntervalMinutes: 99,
eventDurationSeconds: 10,
acceptInvitations: true,
};
constructor() {
super();
this.initLogger();
this.initTransportStreams();
this.initEventListeners();
this.ready();
}
initLogger() {
this.log = new tslog_1.Logger({
name: `[${package_json_1.version}]`,
prettyLogTemplate: '{{name}}\t{{logLevelName}}\t[{{fileNameWithLine}}]\t',
prettyErrorTemplate: '\n{{errorName}} {{errorMessage}}\nerror stack:\n{{errorStack}}',
prettyErrorStackTemplate: ' • {{fileName}}\t{{method}}\n\t{{fileNameWithLine}}',
prettyErrorParentNamesSeparator: ':',
prettyErrorLoggerNameDelimiter: '\t',
stylePrettyLogs: true,
minLevel: 2,
prettyLogTimeZone: 'local',
prettyLogStyles: {
logLevelName: {
'*': ['bold', 'black', 'bgWhiteBright', 'dim'],
SILLY: ['bold', 'white'],
TRACE: ['bold', 'whiteBright'],
DEBUG: ['bold', 'green'],
INFO: ['bold', 'blue'],
WARN: ['bold', 'yellow'],
ERROR: ['bold', 'red'],
FATAL: ['bold', 'redBright'],
},
dateIsoStr: 'gray',
filePathWithLine: 'white',
name: 'green',
nameWithDelimiterPrefix: ['white', 'bold'],
nameWithDelimiterSuffix: ['white', 'bold'],
errorName: ['bold', 'bgRedBright', 'whiteBright'],
fileName: ['yellow'],
},
});
this.tsLog = new tslog_1.Logger({ type: 'hidden', minLevel: 2 });
}
initTransportStreams() {
if (!fs.existsSync(this.storagePath)) {
fs.mkdirSync(this.storagePath);
}
const pluginLogStream = (0, rotating_file_stream_1.createStream)('configui-server.log', { path: this.storagePath, interval: '1d', rotate: 3, maxSize: '200M' });
const pluginLogLibStream = (0, rotating_file_stream_1.createStream)('configui-lib.log', { path: this.storagePath, interval: '1d', rotate: 3, maxSize: '200M' });
this.log.attachTransport((logObj) => pluginLogStream.write(JSON.stringify(logObj) + '\n'));
this.tsLog.attachTransport((logObj) => pluginLogLibStream.write(JSON.stringify(logObj) + '\n'));
this.log.debug('Using bropats eufy-security-client library in version ' + eufy_security_client_1.libVersion);
}
initEventListeners() {
this.onRequest('/login', this.login.bind(this));
this.onRequest('/storedAccessories', this.loadStoredAccessories.bind(this));
this.onRequest('/reset', this.resetPlugin.bind(this));
this.onRequest('/downloadLogs', this.downloadLogs.bind(this));
this.onRequest('/nodeJSVersion', this.nodeJSVersion.bind(this));
}
/**
* Deletes a file if it exists.
*
* @param filePath The path to the file to be deleted.
* @returns A Promise that resolves when the operation is complete, or rejects if an error occurs.
* @throws Will reject the Promise with the error if file deletion fails.
*/
async deleteFileIfExists(filePath) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
catch (error) {
return Promise.reject(error);
}
}
/**
* Resets the persistent data by removing the persistent.json file.
*
* @returns A Promise that resolves when the operation is complete, or rejects if an error occurs.
*/
async resetPersistentData() {
return this.deleteFileIfExists(this.storagePath + '/persistent.json');
}
/**
* Resets the accessory data by removing the stored accessories file.
*
* @returns A Promise that resolves when the operation is complete, or rejects if an error occurs.
*/
async resetAccessoryData() {
return this.deleteFileIfExists(this.storedAccessories_file);
}
/**
* Checks compatibility of the current Node.js version with Livestream functionality.
*/
nodeJSVersion() {
// Define versions known to break compatibility with RSA_PKCS1_PADDING
const nodeJSIncompatible = (0, semver_1.satisfies)(node_process_1.version, '>=18.19.1 <19.x || >=20.11.1 <21.x || >=21.6.2 <22');
return {
nodeJSversion: node_process_1.version,
nodeJSIncompatible: nodeJSIncompatible,
nodeJSargs: node_process_1.argv,
nodeJSenv: node_process_1.env,
};
}
async login(options) {
try {
if (options && options.username && options.password) {
this.log.info('deleting persistent.json and accessories due to new login');
this.resetAccessoryData();
await this.resetPersistentData(); // To be commented for testing purpose
}
}
catch (error) {
this.log.error('Could not delete persistent.json due to error: ' + error);
}
if (!this.eufyClient && options && options.username && options.password && options.country) {
this.stations = [];
this.log.debug('init eufyClient');
this.config.username = options.username;
this.config.password = options.password;
this.config.country = options.country;
this.config.trustedDeviceName = options.deviceName;
try {
this.eufyClient = await eufy_security_client_1.EufySecurity.initialize(this.config, this.tsLog);
this.eufyClient?.on('station added', await this.addStation.bind(this));
this.eufyClient?.on('device added', await this.addDevice.bind(this));
// Close connection after 40 seconds enough time to get all devices
setTimeout(() => {
this.eufyClient?.removeAllListeners();
this.eufyClient?.close();
}, 40 * 1000);
}
catch (error) {
this.log.error(error);
}
}
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ success: false, failReason: types_1.LoginFailReason.TIMEOUT });
}, 25 * 1000);
if (options && options.username && options.password && options.country) {
this.log.debug('login with credentials');
try {
this.loginHandlers(resolve);
this.eufyClient?.connect()
.then(() => this.log.debug('connected?: ' + this.eufyClient?.isConnected()))
.catch((error) => this.log.error(error));
}
catch (error) {
this.log.error(error);
resolve({ success: false, failReason: types_1.LoginFailReason.UNKNOWN, data: { error: error } });
}
}
else if (options && options.verifyCode) {
try {
this.loginHandlers(resolve);
this.eufyClient?.connect({ verifyCode: options.verifyCode, force: false });
}
catch (error) {
resolve({ success: false, failReason: types_1.LoginFailReason.UNKNOWN, data: { error: error } });
}
}
else if (options && options.captcha) {
try {
this.loginHandlers(resolve);
this.eufyClient?.connect({ captcha: { captchaCode: options.captcha.captchaCode, captchaId: options.captcha.captchaId }, force: false });
}
catch (error) {
resolve({ success: false, failReason: types_1.LoginFailReason.UNKNOWN, data: { error: error } });
}
}
else {
reject('unsupported login method');
}
});
}
loginHandlers(resolveCallback) {
this.eufyClient?.once('tfa request', () => resolveCallback({ success: false, failReason: types_1.LoginFailReason.TFA }));
this.eufyClient?.once('captcha request', (id, captcha) => resolveCallback({ success: false, failReason: types_1.LoginFailReason.CAPTCHA, data: { id: id, captcha: captcha } }));
this.eufyClient?.once('connect', () => resolveCallback({ success: true }));
}
/**
* Asynchronously loads stored accessories from a file.
* @returns A promise resolving to an array of accessories.
*/
async loadStoredAccessories() {
try {
// Check if the stored accessories file exists
if (!fs.existsSync(this.storedAccessories_file)) {
// If the file doesn't exist, log a warning and return an empty array
this.log.debug('Stored accessories file does not exist.');
return [];
}
// Read the content of the stored accessories file asynchronously
const storedData = await fs.promises.readFile(this.storedAccessories_file, { encoding: 'utf-8' });
// Parse the JSON data obtained from the file
const { version: storedVersion, stations: storedAccessories } = JSON.parse(storedData);
// Compare the stored version with the current version
if (storedVersion !== package_json_1.version) {
// If the versions do not match, log a warning and push an event
this.pushEvent('versionUnmatched', { currentVersion: package_json_1.version, storedVersion: storedVersion });
this.log.warn(`Stored version (${storedVersion}) does not match current version (${package_json_1.version})`);
}
// Return the parsed accessories
return storedAccessories;
}
catch (error) {
// If an error occurs during the process, log an error message and return an empty array
this.log.error('Could not get stored accessories. Most likely no stored accessories yet: ' + error);
return [];
}
}
async delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async addStation(station) {
// Before doing anything check if creds are guest admin
const rawStation = station.getRawStation();
if (rawStation.member.member_type !== eufy_security_client_1.UserType.ADMIN) {
this.adminAccountUsed = true;
this.eufyClient?.close();
this.pushEvent('AdminAccountUsed', true);
this.resetPlugin();
this.log.error(`
#########################
######### ERROR #########
#########################
You're not using a guest admin account with this plugin! You must use a guest admin account!
Please look here for more details:
https://github.com/homebridge-eufy-security/plugin/wiki/Create-a-dedicated-admin-account-for-Homebridge-Eufy-Security-Plugin
#########################
`);
return;
}
await this.delay(1000);
const s = {
uniqueId: station.getSerial(),
displayName: station.getName(),
type: station.getDeviceType(),
typename: eufy_security_client_1.DeviceType[station.getDeviceType()],
disabled: false,
devices: [],
};
s.ignored = (this.config['ignoreStations'] ?? []).includes(s.uniqueId);
// Standalone Lock or Doorbell doesn't have Security Control
if (eufy_security_client_1.Device.isLock(s.type) || eufy_security_client_1.Device.isDoorbell(s.type)) {
s.disabled = true;
s.ignored = true;
}
this.stations.push(s);
this.storeAccessories();
this.pushEvent('addAccessory', this.stations);
}
async addDevice(device) {
// Before doing anything check if creds are guest admin
if (this.adminAccountUsed) {
this.pushEvent('AdminAccountUsed', true);
return;
}
await this.delay(2000);
const d = {
uniqueId: device.getSerial(),
displayName: device.getName(),
type: device.getDeviceType(),
typename: eufy_security_client_1.DeviceType[device.getDeviceType()],
standalone: device.getSerial() === device.getStationSerial(),
hasBattery: device.hasBattery(),
isCamera: device.isCamera(),
isDoorbell: device.isDoorbell(),
isKeypad: device.isKeyPad(),
supportsRTSP: device.hasPropertyValue(eufy_security_client_1.PropertyName.DeviceRTSPStream),
supportsTalkback: device.hasCommand(eufy_security_client_1.CommandName.DeviceStartTalkback),
DeviceEnabled: device.hasProperty(eufy_security_client_1.PropertyName.DeviceEnabled),
DeviceMotionDetection: device.hasProperty(eufy_security_client_1.PropertyName.DeviceMotionDetection),
DeviceLight: device.hasProperty(eufy_security_client_1.PropertyName.DeviceLight),
DeviceChimeIndoor: device.hasProperty(eufy_security_client_1.PropertyName.DeviceChimeIndoor),
disabled: false,
properties: device.getProperties(),
};
if (device.hasProperty(eufy_security_client_1.PropertyName.DeviceChargingStatus)) {
d.chargingStatus = device.getPropertyValue(eufy_security_client_1.PropertyName.DeviceChargingStatus);
}
try {
delete d.properties.picture;
}
catch (error) {
this.log.error(error);
}
d.ignored = (this.config['ignoreDevices'] ?? []).includes(d.uniqueId);
const stationUniqueId = device.getStationSerial();
const stationIndex = this.stations.findIndex(station => station.uniqueId === stationUniqueId);
if (stationIndex !== -1) {
if (!this.stations[stationIndex].devices) {
this.stations[stationIndex].devices = [];
}
this.stations[stationIndex].devices.push(d);
this.storeAccessories();
this.pushEvent('addAccessory', this.stations);
}
else {
this.log.error('Station not found for device:', d.displayName);
}
}
storeAccessories() {
const dataToStore = { version: package_json_1.version, stations: this.stations };
fs.writeFileSync(this.storedAccessories_file, JSON.stringify(dataToStore));
}
async resetPlugin() {
try {
fs.rmSync(this.storagePath, { recursive: true });
return { result: 1 };
}
catch (error) {
this.log.error('Could not reset plugin: ' + error);
return { result: 0 };
}
}
async getLogs() {
// Step 1: List Files in Directory
// Asynchronously list all files in the directory specified by this.storagePath.
const files = await fs.promises.readdir(this.storagePath);
// Step 2: Filter Log Files
// Filter the list of files to include only those with names ending in .log, .log.0.
const logFiles = files.filter(file => {
return file.endsWith('.log') || file.endsWith('.log.0');
});
// Step 3: Filter out Empty Log Files
const nonEmptyLogFiles = await Promise.all(logFiles.map(async (file) => {
const filePath = path_1.default.join(this.storagePath, file);
const stats = await fs.promises.stat(filePath);
if (stats.size > 0) {
return file;
}
return null;
}));
// Step 4: Remove null entries (empty log files) from the array
return nonEmptyLogFiles.filter(file => file !== null);
}
/**
* Asynchronously compresses log files from a directory and returns a Promise that resolves to a Buffer.
* @returns {Promise<Buffer>} A Promise resolving to a Buffer containing compressed log files.
*/
async downloadLogs() {
this.pushEvent('downloadLogsProgress', { progress: 10, status: 'Gets non-empty log files' });
const finalLogFiles = await this.getLogs();
// Step 5: Add Log Files to Zip
// Initialize a Zip instance and add each log file to the archive.
this.pushEvent('downloadLogsProgress', { progress: 30, status: 'Add Log Files to Zip' });
const zip = new zip_lib_1.Zip();
let numberOfFiles = 0;
finalLogFiles.forEach(logFile => {
const filePath = path_1.default.join(this.storagePath, logFile);
zip.addFile(filePath);
numberOfFiles++;
});
// Step 6: Handle No Log Files Found
// If no log files are found after filtering, throw an error.
this.pushEvent('downloadLogsProgress', { progress: 40, status: 'No Log Files Found' });
if (numberOfFiles === 0) {
throw new Error('No log files were found');
}
try {
// Step 7: Archive Zip
// Archive the Zip instance to the specified log zip file.
this.pushEvent('downloadLogsProgress', { progress: 45, status: `Compressing ${numberOfFiles} files` });
await zip.archive(this.logZipFilePath);
// Step 8: Read Zip File
// Read the content of the generated log zip file into a Buffer.
this.pushEvent('downloadLogsProgress', { progress: 80, status: 'Reading content' });
const fileBuffer = fs.readFileSync(this.logZipFilePath);
// Step 9: Return Buffer
// Return the Buffer containing the compressed log files.
this.pushEvent('downloadLogsProgress', { progress: 90, status: 'Returning zip file' });
return fileBuffer;
}
catch (error) {
// Step 10: Error Handling
// Log an error if archiving the zip file fails and propagate the error.
this.log.error('Error while generating log files: ' + error);
throw error;
}
finally {
// Step 11: Cleanup
// Ensure to remove any compressed log files after the operation, regardless of success or failure.
this.removeCompressedLogs();
}
}
removeCompressedLogs() {
try {
if (fs.existsSync(this.logZipFilePath)) {
fs.unlinkSync(this.logZipFilePath);
}
return true;
}
catch {
return false;
}
}
}
// Start the instance of the server
new UiServer();
//# sourceMappingURL=server.js.map
;