UNPKG

homebridge-eufy-security

Version:
461 lines 21.1 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 }); 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