homebridge-eufy-security
Version:
Control Eufy Security from homebridge.
1,328 lines (1,178 loc) • 50 kB
JavaScript
import { EufySecurity, libVersion, Device, PropertyName, CommandName, DeviceType, UserType } from 'eufy-security-client';
import * as fs from 'fs';
import { Logger as TsLogger } from 'tslog';
import { createStream } from 'rotating-file-stream';
import { Zip } from 'zip-lib';
import { HomebridgePluginUiServer } from '@homebridge/plugin-ui-utils';
import path from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const { version: LIB_VERSION } = require('../package.json');
/** Max time (ms) to wait for the client to populate raw data on unsupported items. */
const UNSUPPORTED_INTEL_WAIT_MS = 2 * 60 * 1000; // 2 minutes
class UiServer extends HomebridgePluginUiServer {
stations = [];
eufyClient = null;
log;
tsLog;
storagePath;
storedAccessories_file;
diagnosticsZipFilePath;
adminAccountUsed = false;
// Batch processing for stations and devices
pendingStations = [];
pendingDevices = [];
processingTimeout;
/** Set to true when the user clicks "Skip" in the UI to abort the unsupported intel wait. */
_skipIntelWait = false;
/** Current discovery phase — exposed via /discoveryState for UI catch-up. */
_discoveryPhase = 'idle';
/** Seconds to wait after the last station/device event before processing. */
static DISCOVERY_DEBOUNCE_SEC = 15;
/** Seconds to wait after auth before giving up on device discovery. */
static DISCOVERY_INACTIVITY_SEC = 30;
config = {
username: '',
password: '',
language: 'en',
country: 'US',
trustedDeviceName: 'My Phone',
persistentDir: '',
p2pConnectionSetup: 0,
pollingIntervalMinutes: 1,
eventDurationSeconds: 10,
acceptInvitations: true,
logging: {
level: 1, // LogLevel.Debug — enables eufy-security-client internal logging to configui-lib.log
},
};
constructor() {
super();
this.storagePath = this.homebridgeStoragePath + '/eufysecurity';
this.storedAccessories_file = this.storagePath + '/accessories.json';
this.unsupported_file = this.storagePath + '/unsupported.json';
this.diagnosticsZipFilePath = null; // generated dynamically with timestamp
this.config.persistentDir = this.storagePath;
this.initLogger();
this.initTransportStreams();
this.initEventListeners();
this.ready();
}
/**
* Compute a unified power descriptor from a properties object.
* Works for both devices and stations.
* @param {object} props - the properties object (from device.getProperties() or station.getProperties())
* @returns {{ source: string, icon: string, label: string, battery?: number, batteryLow?: boolean }}
* source: 'battery' | 'solar' | 'plugged' | null
* icon: icon filename for the UI
* label: display text for the UI
* battery: percentage (0-100) if available
* batteryLow: true/false for simple sensors without percentage
*/
_computePower(props) {
const power = { source: null, icon: null, label: null };
// Battery level
if (props.battery !== undefined) {
power.battery = props.battery;
} else if (props.batteryLow !== undefined) {
// Simple sensors only expose batteryLow boolean
power.batteryLow = props.batteryLow;
}
// Charging status (bitmask)
if (props.chargingStatus !== undefined) {
const cs = props.chargingStatus;
const isSolar = ((cs >> 2) & 1) === 1;
const isPlugSolar = ((cs >> 3) & 1) === 1;
const isUsb = (cs & 1) === 1;
if (isSolar || isPlugSolar) {
power.source = 'solar';
power.icon = 'solar_power.svg';
power.label = 'Solar Charging';
return power;
}
if (isUsb) {
power.source = 'plugged';
power.icon = 'bolt.svg';
power.label = 'Charging';
return power;
}
}
// PowerSource property (cameras with battery/solar panel)
// 0 = BATTERY, 1 = SOLAR_PANEL
if (props.powerSource === 1) {
power.source = 'solar';
power.icon = 'solar_power.svg';
power.label = 'Solar';
} else if (props.powerSource === 0) {
power.source = 'battery';
} else if (power.battery === undefined && power.batteryLow === undefined) {
// No battery info at all — AC powered (indoor cameras, stations)
power.source = 'plugged';
power.icon = 'bolt.svg';
power.label = 'Plugged In';
} else {
// Has battery/batteryLow but no powerSource — simple battery device (sensors)
power.source = 'battery';
}
return power;
}
initLogger() {
const logOptions = {
name: `[UI-${LIB_VERSION}]`, // Name prefix for log messages
prettyLogTemplate: '[{{mm}}/{{dd}}/{{yyyy}}, {{hh}}:{{MM}}:{{ss}}]\t{{name}}\t{{logLevelName}}\t', // Template for pretty log output
prettyErrorTemplate: '\n{{errorName}} {{errorMessage}}\nerror stack:\n{{errorStack}}', // Template for pretty error output
prettyErrorStackTemplate: ' • {{fileName}}\t{{method}}\n\t{{fileNameWithLine}}', // Template for error stack trace
prettyErrorParentNamesSeparator: '', // Separator for parent names in error messages
prettyErrorLoggerNameDelimiter: '\t', // Delimiter for logger name in error messages
stylePrettyLogs: true, // Enable styling for logs
minLevel: 2, // Minimum log level to display (3 corresponds to INFO)
prettyLogTimeZone: 'local', // Time zone for log timestamps
prettyLogStyles: { // Styles for different log elements
logLevelName: { // Styles for log level names
'*': ['bold', 'black', 'bgWhiteBright', 'dim'], // Default style
SILLY: ['bold', 'white'], // Style for SILLY level
TRACE: ['bold', 'whiteBright'], // Style for TRACE level
DEBUG: ['bold', 'green'], // Style for DEBUG level
INFO: ['bold', 'blue'], // Style for INFO level
WARN: ['bold', 'yellow'], // Style for WARN level
ERROR: ['bold', 'red'], // Style for ERROR level
FATAL: ['bold', 'redBright'], // Style for FATAL level
},
dateIsoStr: 'gray', // Style for ISO date strings
filePathWithLine: 'white', // Style for file paths with line numbers
name: 'green', // Style for logger names
nameWithDelimiterPrefix: ['white', 'bold'], // Style for logger names with delimiter prefix
nameWithDelimiterSuffix: ['white', 'bold'], // Style for logger names with delimiter suffix
errorName: ['bold', 'bgRedBright', 'whiteBright'], // Style for error names
fileName: ['yellow'], // Style for file names
},
maskValuesOfKeys: [ // Keys whose values should be masked in logs
'username',
'password',
'token',
'clientPrivateKey',
'private_key',
'login_hash',
'serverPublicKey',
'cloud_token',
'refreshToken',
'p2p_conn',
'app_conn',
'address',
'latitude',
'longitude',
'serialnumber',
'serialNumber',
'stationSerialNumber',
'data',
'ignoreStations',
'ignoreDevices',
'pincode',
],
};
this.log = new TsLogger(logOptions);
this.tsLog = new TsLogger({ ...logOptions, type: 'hidden', minLevel: 2 });
}
initTransportStreams() {
if (!fs.existsSync(this.storagePath)) {
fs.mkdirSync(this.storagePath, { recursive: true });
}
const logStreams = [
{ name: 'configui-server.log', logger: this.log },
{ name: 'configui-lib.log', logger: this.tsLog },
];
for (const { name, logger } of logStreams) {
const logStream = createStream(name, { path: this.storagePath, interval: '1d', rotate: 3, maxSize: '200M', compress: 'gzip' });
logger.attachTransport((logObj) => {
const meta = logObj['_meta'];
const logName = meta.name;
const level = meta.logLevelName;
const date = meta.date.toISOString();
let message = '';
for (let i = 0; i <= 5; i++) {
if (logObj[i]) {
message += ' ' + (typeof logObj[i] === 'string' ? logObj[i] : JSON.stringify(logObj[i]));
}
}
logStream.write(date + '\t' + logName + '\t' + level + '\t' + message + '\n');
});
}
this.log.debug('Using bropats eufy-security-client library in version ' + libVersion);
}
initEventListeners() {
this.onRequest('/login', this.login.bind(this));
this.onRequest('/checkCache', this.checkCache.bind(this));
this.onRequest('/storedAccessories', this.loadStoredAccessories.bind(this));
this.onRequest('/reset', this.resetPlugin.bind(this));
this.onRequest('/downloadDiagnostics', this.downloadDiagnostics.bind(this));
this.onRequest('/cleanStorage', this.cleanStorage.bind(this));
this.onRequest('/systemInfo', this.getSystemInfo.bind(this));
this.onRequest('/skipIntelWait', this.skipIntelWait.bind(this));
this.onRequest('/discoveryState', this.getDiscoveryState.bind(this));
this.onRequest('/unsupportedDevices', this.loadUnsupportedDevices.bind(this));
}
skipIntelWait() {
this._skipIntelWait = true;
this.log.info('User requested to skip unsupported intel wait');
return { ok: true };
}
/**
* Load valid country codes from the shared countries.js file.
* Parsed lazily and cached for subsequent calls.
* @returns {Set<string>}
*/
_getValidCountryCodes() {
if (!this._validCountryCodes) {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const source = fs.readFileSync(path.join(__dirname, 'public/utils/countries.js'), 'utf-8');
this._validCountryCodes = new Set(source.match(/\b[A-Z]{2}(?=\s*:)/g));
}
return this._validCountryCodes;
}
getDiscoveryState() {
return {
phase: this._discoveryPhase,
progress: this._discoveryPhase === 'queuing' ? 30 : this._discoveryPhase === 'processing' ? 50 : 0,
stations: this.pendingStations.length,
devices: this.pendingDevices.length,
message: this.pendingStations.length > 0 || this.pendingDevices.length > 0
? `Discovered ${this.pendingStations.length} station(s), ${this.pendingDevices.length} device(s)...`
: '',
};
}
async deleteFileIfExists(filePath) {
try {
await fs.promises.unlink(filePath);
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
}
async resetPersistentData() {
return this.deleteFileIfExists(this.storagePath + '/persistent.json');
}
async resetAccessoryData() {
return this.deleteFileIfExists(this.storedAccessories_file);
}
async checkCache() {
const persistentFile = this.storagePath + '/persistent.json';
try {
if (fs.existsSync(persistentFile)) {
const data = JSON.parse(await fs.promises.readFile(persistentFile, 'utf-8'));
// Basic validity check: ensure it has some expected content
if (data && Object.keys(data).length > 0) {
this.log.debug('Persistent cache file found and valid.');
return { valid: true };
}
}
} catch (error) {
this.log.warn('Error checking persistent cache: ' + error);
}
return { valid: false };
}
async login(options) {
// --- Plugin heartbeat safeguard ---
// If the plugin is running (accessories.json updated within the last 90s),
// block login to prevent a competing eufy-security-client instance.
if (!this.eufyClient) {
try {
if (fs.existsSync(this.storedAccessories_file)) {
const data = JSON.parse(fs.readFileSync(this.storedAccessories_file, 'utf-8'));
if (data?.storedAt) {
const ageMs = Date.now() - new Date(data.storedAt).getTime();
if (ageMs < 90_000) {
this.log.warn('Plugin heartbeat is fresh — blocking UI login to avoid duplicate eufy client. Please stop and wait 90sec before trying again!');
this.pushEvent('authError', {
message: 'The plugin is currently running. Please stop it before logging in from the UI. Please stop and wait 90sec before trying again!',
});
return { success: false, pluginRunning: true };
}
}
}
} catch (error) {
this.log.debug('Heartbeat check failed (non-blocking): ' + error);
}
}
try {
if (options && options.username && options.password && !options.reconnect) {
this.log.info('deleting persistent.json and accessories due to new login');
await this.resetAccessoryData();
await this.resetPersistentData();
} else if (options && options.reconnect) {
this.log.info('Reconnecting using persistent cache (skipping data reset)');
}
} catch (error) {
this.log.error('Could not delete persistent.json due to error: ' + error);
}
if (!this.eufyClient && options && options.username && options.password && options.country) {
// Clear any pending timeouts from a previous login attempt
if (this.processingTimeout) {
clearTimeout(this.processingTimeout);
this.processingTimeout = null;
}
if (this._closeTimeout) {
clearTimeout(this._closeTimeout);
this._closeTimeout = null;
}
this.stations = [];
this.pendingStations = [];
this.pendingDevices = [];
this._discoveryPhase = 'authenticating';
this.log.debug('init eufyClient');
// Validate country code against known list
const country = typeof options.country === 'string' ? options.country.trim().toUpperCase() : '';
if (!this._getValidCountryCodes().has(country)) {
const raw = typeof options.country === 'object' ? JSON.stringify(options.country) : String(options.country);
this.log.warn(`Invalid country code received: ${raw} — falling back to login.`);
this.pushEvent('authError', { message: `Invalid country code "${raw}". Please select a valid country and try again.` });
this._discoveryPhase = 'idle';
return { success: false };
}
this.config.username = options.username;
this.config.password = options.password;
this.config.country = country;
this.config.trustedDeviceName = options.deviceName;
try {
this.eufyClient = await EufySecurity.initialize(this.config, this.tsLog);
this.eufyClient?.on('station added', this.addStation.bind(this));
this.eufyClient?.on('device added', this.addDevice.bind(this));
this.eufyClient?.on('push connect', () => this.log.debug('Push Connected!'));
this.eufyClient?.on('push close', () => this.log.debug('Push Closed!'));
this.eufyClient?.on('connect', () => this.log.debug('Connected!'));
this.eufyClient?.on('close', () => this.log.debug('Closed!'));
} catch (error) {
this.log.error(error);
this.pushEvent('authError', { message: `Initialization failed: ${error.message || error}` });
this._discoveryPhase = 'idle';
return { success: false };
}
}
// Timeout — fire authError event after 25s if nothing else resolved
this._loginTimeout = setTimeout(() => {
this.pushEvent('authError', { message: 'Authentication timed out. Please try again.' });
}, 25 * 1000);
if (options && options.username && options.password && options.country) {
this.log.debug('login with credentials');
try {
this._registerAuthHandlers();
this.eufyClient?.connect()
.then(() => this.log.debug('connected?: ' + this.eufyClient?.isConnected()))
.catch((error) => this.log.error(error));
} catch (error) {
this.log.error(error);
clearTimeout(this._loginTimeout);
this.pushEvent('authError', { message: 'Login error: ' + (error.message || error) });
}
} else if (options && options.verifyCode) {
this.log.debug('login with TFA code');
this.pushEvent('discoveryProgress', {
phase: 'authenticating',
progress: 10,
message: 'Verifying TFA code...',
});
try {
this._registerAuthHandlers();
this.eufyClient?.connect({ verifyCode: options.verifyCode, force: false })
.then(() => this.log.debug('TFA connect resolved, connected?: ' + this.eufyClient?.isConnected()))
.catch((error) => {
this.log.error('TFA connect error: ' + error);
clearTimeout(this._loginTimeout);
this.pushEvent('authError', { message: 'TFA verification failed: ' + (error.message || error) });
});
} catch (error) {
clearTimeout(this._loginTimeout);
this.pushEvent('authError', { message: 'TFA verification error: ' + (error.message || error) });
}
} else if (options && options.captcha) {
this.log.debug('login with captcha');
this.pushEvent('discoveryProgress', {
phase: 'authenticating',
progress: 10,
message: 'Verifying captcha...',
});
try {
this._registerAuthHandlers();
this.eufyClient?.connect({ captcha: { captchaCode: options.captcha.captchaCode, captchaId: options.captcha.captchaId }, force: false })
.then(() => this.log.debug('Captcha connect resolved, connected?: ' + this.eufyClient?.isConnected()))
.catch((error) => {
this.log.error('Captcha connect error: ' + error);
clearTimeout(this._loginTimeout);
this.pushEvent('authError', { message: 'Captcha verification failed: ' + (error.message || error) });
});
} catch (error) {
clearTimeout(this._loginTimeout);
this.pushEvent('authError', { message: 'Captcha verification error: ' + (error.message || error) });
}
} else {
clearTimeout(this._loginTimeout);
this.pushEvent('authError', { message: 'Unsupported login method.' });
}
// Resolve immediately — all outcomes are delivered via push events
return { pending: true };
}
/**
* Register one-time auth outcome handlers on the eufy client.
* All outcomes are delivered to the UI via push events.
*/
_registerAuthHandlers() {
this.eufyClient?.once('tfa request', () => {
clearTimeout(this._loginTimeout);
this.pushEvent('tfaRequest', {});
});
this.eufyClient?.once('captcha request', (id, captcha) => {
clearTimeout(this._loginTimeout);
this.pushEvent('captchaRequest', { id, captcha });
});
this.eufyClient?.once('connect', () => {
clearTimeout(this._loginTimeout);
this.pushEvent('authSuccess', {});
this.pushEvent('discoveryProgress', {
phase: 'authenticating',
progress: 15,
message: 'Authenticated — waiting for devices...',
});
this._startDiscoveryInactivityTimeout();
});
}
/**
* Start the discovery inactivity timeout.
* If no station or device is discovered within DISCOVERY_INACTIVITY_SEC seconds
* after authentication, save the account and send an empty result to the UI.
*/
_startDiscoveryInactivityTimeout() {
// If stations or devices were already discovered before connect fired, skip
if (this.pendingStations.length > 0 || this.pendingDevices.length > 0) {
this.log.debug('Devices already discovered before connect event — skipping inactivity timeout');
return;
}
this._cancelDiscoveryInactivityTimeout();
const totalSec = UiServer.DISCOVERY_INACTIVITY_SEC;
const start = Date.now();
// Tick every second: progress 15 → 95 during the wait, with countdown
this._discoveryInactivityTickInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - start) / 1000);
const remaining = Math.max(0, totalSec - elapsed);
const pct = Math.min(95, 15 + Math.floor((elapsed / totalSec) * 80));
this.pushEvent('discoveryProgress', {
phase: 'waitingForDevices',
progress: pct,
message: `Authenticated — waiting for devices... ${remaining}s`,
});
}, 1000);
this._discoveryInactivityTimeout = setTimeout(() => {
clearInterval(this._discoveryInactivityTickInterval);
this._discoveryInactivityTickInterval = null;
this.log.warn(
`No stations or devices discovered within ${totalSec}s after authentication. ` +
'The account may have no devices or the guest invitation has not been accepted yet.',
);
this._discoveryPhase = 'done';
this.stations = [];
try {
this.storeAccessories();
} catch (error) {
this.log.error('Error storing empty accessories:', error);
}
this.pushEvent('discoveryProgress', {
phase: 'done',
progress: 100,
message: 'No devices found.',
});
this.pushEvent('addAccessory', { stations: [], noDevices: true });
this.eufyClient?.removeAllListeners();
this.eufyClient?.close();
}, totalSec * 1000);
}
/**
* Cancel the discovery inactivity timeout (called when a station or device is discovered).
*/
_cancelDiscoveryInactivityTimeout() {
if (this._discoveryInactivityTickInterval) {
clearInterval(this._discoveryInactivityTickInterval);
this._discoveryInactivityTickInterval = null;
}
if (this._discoveryInactivityTimeout) {
clearTimeout(this._discoveryInactivityTimeout);
this._discoveryInactivityTimeout = null;
}
}
/**
* Parse a semver string into [major, minor, patch].
* @param {string} ver - e.g. '4.4.2-beta.18'
* @returns {number[]}
*/
_parseSemver(ver) {
return (ver || '0.0.0').replace(/-.*$/, '').split('.').map(Number);
}
async loadStoredAccessories() {
try {
if (!fs.existsSync(this.storedAccessories_file)) {
this.log.debug('Stored accessories file does not exist.');
return [];
}
const storedData = await fs.promises.readFile(this.storedAccessories_file, { encoding: 'utf-8' });
const { version: storedVersion, storedAt, stations: storedAccessories } = JSON.parse(storedData);
// --- Cache age check (30 days) ---
if (storedAt) {
const ageMs = Date.now() - new Date(storedAt).getTime();
const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24));
if (ageDays >= 30) {
this.pushEvent('cacheWarning', { reason: 'stale', ageDays });
this.log.warn(`Stored accessories are ${ageDays} days old. User should re-login to refresh.`);
}
}
// --- Version branch check ---
if (storedVersion && storedVersion !== LIB_VERSION) {
const [curMajor, curMinor] = this._parseSemver(LIB_VERSION);
const [stoMajor, stoMinor] = this._parseSemver(storedVersion);
if (curMajor !== stoMajor || curMinor !== stoMinor) {
// Different minor (or major) branch → force re-login
this.pushEvent('cacheWarning', {
reason: 'versionForce',
currentVersion: LIB_VERSION,
storedVersion,
});
this.log.warn(`Stored version (${storedVersion}) is on a different branch than current (${LIB_VERSION}). Forcing re-login.`);
return { stations: [], storedAt: null }; // Return empty to force login flow
} else {
// Same minor branch, different patch → soft warning
this.pushEvent('cacheWarning', {
reason: 'versionWarn',
currentVersion: LIB_VERSION,
storedVersion,
});
this.log.warn(`Stored version (${storedVersion}) differs from current (${LIB_VERSION}) but same branch. Consider re-login.`);
}
}
return { stations: storedAccessories, storedAt: storedAt || null };
} catch (error) {
this.log.error('Could not get stored accessories. Most likely no stored accessories yet: ' + error);
return { stations: [], storedAt: null };
}
}
async delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async addStation(station) {
// Check if creds are guest admin
const rawStation = station.getRawStation();
if (rawStation.member.member_type !== 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-plugins/homebridge-eufy-security/wiki/Create-a-dedicated-admin-account-for-Homebridge-Eufy-Security-Plugin
#########################
`);
return;
}
this._cancelDiscoveryInactivityTimeout();
this.pendingStations.push(station);
this.log.debug(`${station.getName()}: Station queued for processing`);
this._discoveryPhase = 'queuing';
this.pushEvent('discoveryProgress', {
phase: 'queuing',
progress: 30,
stations: this.pendingStations.length,
devices: this.pendingDevices.length,
message: `Discovered ${this.pendingStations.length} station(s), ${this.pendingDevices.length} device(s)...`,
});
this.resetDiscoveryDebounce();
}
async addDevice(device) {
if (this.adminAccountUsed) {
this.pushEvent('AdminAccountUsed', true);
return;
}
const deviceType = device.getDeviceType();
if (Device.isKeyPad(deviceType)) {
this.log.warn(`${device.getName()}: The keypad is ignored as it serves no purpose in this plugin. You can ignore this message.`);
return;
}
this._cancelDiscoveryInactivityTimeout();
this.pendingDevices.push(device);
this.log.debug(`${device.getName()}: Device queued for processing`);
this._discoveryPhase = 'queuing';
this.pushEvent('discoveryProgress', {
phase: 'queuing',
progress: 30,
stations: this.pendingStations.length,
devices: this.pendingDevices.length,
message: `Discovered ${this.pendingStations.length} station(s), ${this.pendingDevices.length} device(s)...`,
});
this.resetDiscoveryDebounce();
}
/**
* Resets the discovery debounce timer.
* Each time a station or device is emitted, the timer restarts.
* Processing begins once no new events arrive for DISCOVERY_DEBOUNCE_SEC seconds.
*/
resetDiscoveryDebounce() {
if (this.processingTimeout) {
clearTimeout(this.processingTimeout);
}
if (this._closeTimeout) {
clearTimeout(this._closeTimeout);
}
if (this._debounceTickInterval) {
clearInterval(this._debounceTickInterval);
}
const delaySec = UiServer.DISCOVERY_DEBOUNCE_SEC;
this.log.debug(
`Discovery debounce reset — will process in ${delaySec}s if no more devices arrive ` +
`(${this.pendingStations.length} station(s), ${this.pendingDevices.length} device(s) queued)`,
);
// Tick progress from 30 → 95 during the debounce wait
const debounceStart = Date.now();
this._debounceTickInterval = setInterval(() => {
const elapsed = (Date.now() - debounceStart) / 1000;
const pct = Math.min(95, 30 + Math.floor((elapsed / delaySec) * 65));
const remaining = Math.max(0, Math.ceil(delaySec - elapsed));
this.pushEvent('discoveryProgress', {
phase: 'queuing',
progress: pct,
stations: this.pendingStations.length,
devices: this.pendingDevices.length,
message: `Discovered ${this.pendingStations.length} station(s), ${this.pendingDevices.length} device(s) — waiting for more... ${remaining}s`,
});
}, 1000);
this.processingTimeout = setTimeout(() => {
clearInterval(this._debounceTickInterval);
this._debounceTickInterval = null;
this.processPendingAccessories().catch(error => this.log.error('Error processing pending accessories:', error));
}, delaySec * 1000);
// Close connection after processing + potential 2-min unsupported intel wait
const closeAfterSec = delaySec + (UNSUPPORTED_INTEL_WAIT_MS / 1000) + 15;
this._closeTimeout = setTimeout(() => {
this.eufyClient?.removeAllListeners();
this.eufyClient?.close();
}, closeAfterSec * 1000);
}
async processPendingAccessories() {
this.log.debug(`Processing ${this.pendingStations.length} stations and ${this.pendingDevices.length} devices`);
this._discoveryPhase = 'processing';
this.pushEvent('discoveryProgress', {
phase: 'processing',
progress: 95,
stations: this.pendingStations.length,
devices: this.pendingDevices.length,
message: `Processing ${this.pendingStations.length} station(s) and ${this.pendingDevices.length} device(s)...`,
});
if (this.pendingStations.length === 0 || this.pendingDevices.length === 0) {
this.log.warn(
`Discovery finished with ${this.pendingStations.length} station(s) and ${this.pendingDevices.length} device(s). ` +
'If this is unexpected, please verify your Eufy account has devices and the credentials used are for a guest admin account.',
);
}
// --- Collect unsupported items (stations + devices) upfront ---
// Hub/base stations (type 0, HB3, etc.) are not in DeviceProperties so
// Device.isSupported() returns false for them — exclude known station types.
const unsupportedItems = [];
for (const station of this.pendingStations) {
try {
const st = station.getDeviceType();
if (!Device.isStation(st) && !Device.isSupported(st)) unsupportedItems.push(station);
} catch (e) { /* ignore */ }
}
for (const device of this.pendingDevices) {
try {
if (!Device.isSupported(device.getDeviceType())) unsupportedItems.push(device);
} catch (e) { /* ignore */ }
}
// If unsupported items exist, notify UI and wait (user can skip via /skipIntelWait)
if (unsupportedItems.length > 0) {
const names = unsupportedItems.map(i => `${i.getName()} (type ${i.getDeviceType()})`).join(', ');
this._skipIntelWait = false;
this.pushEvent('discoveryWarning', {
unsupportedCount: unsupportedItems.length,
unsupportedNames: names,
waitSeconds: UNSUPPORTED_INTEL_WAIT_MS / 1000,
message: `${unsupportedItems.length} unsupported device(s) detected: ${names}`,
});
this.log.info(`Unsupported intel: waiting up to ${UNSUPPORTED_INTEL_WAIT_MS / 1000}s for raw data (user can skip)`);
// Cancellable wait — check _skipIntelWait every second, ticking progress 50 → 95
const pollMs = 1000;
let waited = 0;
while (waited < UNSUPPORTED_INTEL_WAIT_MS && !this._skipIntelWait) {
await this.delay(pollMs);
waited += pollMs;
const pct = Math.min(95, 50 + Math.floor((waited / UNSUPPORTED_INTEL_WAIT_MS) * 45));
const remaining = Math.max(0, Math.ceil((UNSUPPORTED_INTEL_WAIT_MS - waited) / 1000));
this.pushEvent('discoveryProgress', {
phase: 'unsupportedWait',
progress: pct,
message: `Collecting data for ${unsupportedItems.length} unsupported device(s)... ${remaining}s`,
});
}
if (this._skipIntelWait) {
this.log.info(`Unsupported intel wait skipped by user after ${waited / 1000}s`);
} else {
this.log.info(`Unsupported intel wait completed (${waited / 1000}s)`);
}
}
this.pushEvent('discoveryProgress', {
phase: 'buildingStations',
progress: 96,
message: 'Building station list...',
});
// Process queued stations
for (const station of this.pendingStations) {
const stationType = station.getDeviceType();
const stationSerial = station.getSerial();
const s = {
uniqueId: stationSerial,
displayName: station.getName(),
type: stationType,
typename: DeviceType[stationType],
disabled: false,
devices: [],
properties: station.getProperties(),
unsupported: false,
};
try {
delete s.properties.picture;
} catch (error) {
// ignore
}
s.ignored = (this.config['ignoreStations'] ?? []).includes(s.uniqueId);
// Pre-compute power info for the UI
s.power = this._computePower(s.properties);
if (!Device.isStation(stationType)) {
// Not a hub/base station — the station IS a standalone device (station.type == device.type)
if (!Device.isSupported(stationType)) {
// Device type not recognized by eufy-security-client — truly unsupported
s.unsupported = true;
this.log.warn(`Station "${station.getName()}" (type ${stationType}) is not supported by eufy-security-client`);
// Immediately add the unsupported station and skip further processing
this.stations.push(s);
continue;
} else {
// Check if the matching device was emitted by the client
const hasMatchingDevice = this.pendingDevices.some(d => d.getSerial() === stationSerial);
if (hasMatchingDevice) {
s.standalone = true;
s.disabled = true; // No separate station card; settings accessible via device card
// Standalone Locks, Doorbells and SmartDrops don't have Security Control
if (Device.isLock(s.type) || Device.isDoorbell(s.type) || Device.isSmartDrop(s.type)) {
s.noSecurityControl = true;
}
} else {
// Station exists but no device counterpart was emitted — unsupported
s.unsupported = true;
this.log.warn(`Station "${station.getName()}" (${DeviceType[stationType]}) has no matching device and will be marked as unsupported`);
// Short-circuit processing for unsupported station
this.stations.push(s);
continue;
}
}
}
this.stations.push(s);
}
this.pushEvent('discoveryProgress', {
phase: 'buildingDevices',
progress: 98,
message: 'Building device list...',
});
// Process queued devices and attach them to stations
for (const device of this.pendingDevices) {
const devType = device.getDeviceType();
const d = {
uniqueId: device.getSerial(),
displayName: device.getName(),
type: devType,
typename: DeviceType[devType],
standalone: device.getSerial() === device.getStationSerial(),
hasBattery: device.hasBattery(),
isCamera: device.isCamera() || Device.isLockWifiVideo(devType),
isDoorbell: device.isDoorbell(),
isKeypad: device.isKeyPad(),
isMotionSensor: Device.isMotionSensor(devType),
isEntrySensor: Device.isEntrySensor(devType),
isLock: Device.isLock(devType),
isSmartDrop: Device.isSmartDrop(devType),
supportsRTSP: device.hasPropertyValue(PropertyName.DeviceRTSPStream),
supportsTalkback: device.hasCommand(CommandName.DeviceStartTalkback),
DeviceEnabled: device.hasProperty(PropertyName.DeviceEnabled),
DeviceMotionDetection: device.hasProperty(PropertyName.DeviceMotionDetection),
DeviceLight: device.hasProperty(PropertyName.DeviceLight),
DeviceChimeIndoor: device.hasProperty(PropertyName.DeviceChimeIndoor),
disabled: false,
properties: device.getProperties(),
unsupported: false,
};
// Mark device as unsupported if eufy-security-client doesn't recognize this device type
if (!Device.isSupported(devType)) {
d.unsupported = true;
}
// Pre-compute power info for the UI
d.power = this._computePower(d.properties);
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 parent station is unsupported, propagate flag to device
if (this.stations[stationIndex].unsupported) {
d.unsupported = true;
}
if (!this.stations[stationIndex].devices) {
this.stations[stationIndex].devices = [];
}
this.stations[stationIndex].devices.push(d);
} else {
this.log.error('Station not found for device:', d.displayName);
}
}
// Write unsupported.json with raw device intel for triage (before clearing pending queues)
try {
this.storeUnsupportedDevices(this.pendingStations, this.pendingDevices);
} catch (error) {
this.log.error('Error storing unsupported devices:', error);
}
// Clear pending queues
this.pendingStations = [];
this.pendingDevices = [];
// Always send the final list to the UI, even if empty
try {
this.storeAccessories();
} catch (error) {
this.log.error('Error storing accessories:', error);
}
this.pushEvent('discoveryProgress', {
phase: 'done',
progress: 100,
message: 'Discovery complete!',
});
this.pushEvent('addAccessory', { stations: this.stations, extendedDiscovery: unsupportedItems.length > 0 });
}
storeAccessories() {
if (!fs.existsSync(this.storagePath)) {
fs.mkdirSync(this.storagePath, { recursive: true });
}
const dataToStore = { version: LIB_VERSION, storedAt: new Date().toISOString(), stations: this.stations };
fs.writeFileSync(this.storedAccessories_file, JSON.stringify(dataToStore));
}
// ── Sensitive-field redaction ──────────────────────────────────────────────
// Keys whose string values must be partially masked before persisting to
// unsupported.json. Values are [keepStart, keepEnd] — the number of
// characters to leave visible at the beginning and end of the string.
// An empty / falsy string is left as-is so we can tell the field is blank.
static SENSITIVE_KEYS = new Map([
// Serial numbers — keep model prefix (e.g. T8170)
['station_sn', [5, 0]],
['device_sn', [5, 0]],
// Network / connectivity
['wifi_ssid', [3, 0]],
['wifi_mac', [4, 0]],
['ip_addr', [3, 0]],
['local_ip', [3, 0]],
// Hardware identifiers
['cpuid', [4, 0]],
// P2P identifiers & key-exchange material
['p2p_did', [7, 0]],
['push_did', [7, 0]],
['ndt_did', [7, 0]],
['query_server_did', [7, 0]],
['p2p_conn', [4, 0]],
['app_conn', [4, 0]],
['p2p_license', [2, 0]],
['push_license', [2, 0]],
['ndt_license', [2, 0]],
['wakeup_key', [4, 0]],
['dsk_key', [4, 0]],
['setup_code', [2, 0]],
['setup_id', [2, 0]],
// User / account identifiers
['account_id', [4, 0]],
['admin_user_id', [4, 0]],
['member_user_id', [4, 0]],
['action_user_id', [4, 0]],
['short_user_id', [4, 0]],
['email', [3, 0]],
['action_user_email', [3, 0]],
['member_nick', [3, 0]],
['nick_name', [3, 0]],
['action_user_name', [3, 0]],
['avatar', [0, 0]],
['member_avatar', [0, 0]],
// House / location
['house_id', [4, 0]],
// Misc identifiers
['volume', [6, 0]],
]);
/**
* Partially mask a string value, keeping the first `keepStart` and last
* `keepEnd` characters visible. Returns the original if it is empty or
* too short to meaningfully mask.
*/
static _partialMask(value, keepStart, keepEnd) {
if (typeof value !== 'string' || !value) return value;
const minLen = keepStart + keepEnd + 1;
if (value.length <= minLen) return value.length <= 2 ? '***' : value[0] + '***';
return value.slice(0, keepStart) + '***' + (keepEnd > 0 ? value.slice(-keepEnd) : '');
}
/**
* Deep-walk an object and partially redact every value whose key appears
* in SENSITIVE_KEYS. Returns a new object — the original is not mutated.
*/
static _redactSensitiveFields(obj) {
if (Array.isArray(obj)) {
return obj.map(item => EufySecurityServer._redactSensitiveFields(item));
}
if (obj !== null && typeof obj === 'object') {
const out = {};
for (const [key, val] of Object.entries(obj)) {
const rule = EufySecurityServer.SENSITIVE_KEYS.get(key);
if (rule && typeof val === 'string') {
out[key] = EufySecurityServer._partialMask(val, rule[0], rule[1]);
} else if (typeof val === 'object' && val !== null) {
out[key] = EufySecurityServer._redactSensitiveFields(val);
} else {
out[key] = val;
}
}
return out;
}
return obj;
}
// ── Unsupported device storage ──────────────────────────────────────────
/**
* Collect raw intel for all unsupported devices/stations and write to unsupported.json.
* This data is only used by the Plugin UI for triage and diagnostics.
*/
storeUnsupportedDevices(pendingStations, pendingDevices) {
const unsupportedEntries = [];
// Collect unsupported standalone stations
for (const station of pendingStations) {
const stationType = station.getDeviceType();
if (!Device.isStation(stationType) && !Device.isSupported(stationType)) {
unsupportedEntries.push(this._buildUnsupportedStationEntry(station));
}
}
// Collect unsupported devices
for (const device of pendingDevices) {
if (!Device.isSupported(device.getDeviceType())) {
unsupportedEntries.push(this._buildUnsupportedDeviceEntry(device));
}
}
if (!fs.existsSync(this.storagePath)) {
fs.mkdirSync(this.storagePath, { recursive: true });
}
const dataToStore = { version: LIB_VERSION, storedAt: new Date().toISOString(), devices: unsupportedEntries };
fs.writeFileSync(this.unsupported_file, JSON.stringify(dataToStore));
this.log.debug(`Persisted ${unsupportedEntries.length} unsupported device(s) to unsupported.json`);
}
/**
* Build a triage-ready intel object for an unsupported device.
*/
_buildUnsupportedDeviceEntry(device) {
const rawDevice = device.getRawDevice ? device.getRawDevice() : {};
const rawProps = device.getRawProperties ? device.getRawProperties() : {};
const wifiSsid = rawDevice.wifi_ssid || undefined;
const localIp = rawDevice.ip_addr || rawDevice.local_ip || undefined;
return {
uniqueId: device.getSerial(),
displayName: device.getName(),
type: device.getDeviceType(),
typename: DeviceType[device.getDeviceType()] || undefined,
stationSerialNumber: device.getStationSerial(),
model: rawDevice.device_model,
hardwareVersion: rawDevice.main_hw_version,
softwareVersion: rawDevice.main_sw_version,
wifiSsid: wifiSsid ? EufySecurityServer._partialMask(wifiSsid, 3, 0) : undefined,
localIp: localIp ? EufySecurityServer._partialMask(localIp, 3, 0) : undefined,
rawDevice: EufySecurityServer._redactSensitiveFields(rawDevice),
rawProperties: rawProps,
};
}
/**
* Build a triage-ready intel object for an unsupported standalone station.
*/
_buildUnsupportedStationEntry(station) {
const rawStation = station.getRawStation ? station.getRawStation() : {};
const rawProps = station.getRawProperties ? station.getRawProperties() : {};
const wifiSsid = rawStation.wifi_ssid || undefined;
const localIp = rawStation.ip_addr || undefined;
return {
uniqueId: station.getSerial(),
displayName: station.getName(),
type: station.getDeviceType(),
typename: DeviceType[station.getDeviceType()] || undefined,
stationSerialNumber: station.getSerial(),
model: rawStation.station_model,
hardwareVersion: rawStation.main_hw_version,
softwareVersion: rawStation.main_sw_version,
wifiSsid: wifiSsid ? EufySecurityServer._partialMask(wifiSsid, 3, 0) : undefined,
localIp: localIp ? EufySecurityServer._partialMask(localIp, 3, 0) : undefined,
rawDevice: EufySecurityServer._redactSensitiveFields(rawStation),
rawProperties: rawProps,
};
}
/**
* Load unsupported device intel from disk.
*/
async loadUnsupportedDevices() {
try {
if (!fs.existsSync(this.unsupported_file)) {
return { devices: [] };
}
const data = JSON.parse(await fs.promises.readFile(this.unsupported_file, 'utf-8'));
return { devices: data.devices || [] };
} catch (error) {
this.log.error('Could not load unsupported devices: ' + error);
return { devices: [] };
}
}
async resetPlugin() {
try {
fs.rmSync(this.storagePath, { recursive: true, force: true });
return { result: 1 };
} catch (error) {
this.log.error('Could not reset plugin: ' + error);
return { result: 0 };
}
}
async getLogFiles() {
const files = await fs.promises.readdir(this.storagePath);
const logFiles = files.filter(file => {
return file.endsWith('.log') || file.endsWith('.log.gz');
});
const nonEmptyLogFiles = await Promise.all(logFiles.map(async file => {
const filePath = path.join(this.storagePath, file);
const stats = await fs.promises.stat(filePath);
if (stats.size > 0) {
return file;
}
return null;
}));
return nonEmptyLogFiles.filter(file => file !== null);
}
async downloadDiagnostics() {
this.pushEvent('diagnosticsProgress', { progress: 10, status: 'Collecting log files' });
const finalLogFiles = await this.getLogFiles();
this.pushEvent('diagnosticsProgress', { progress: 30, status: 'Adding files to archive' });
const zip = new Zip();
let numberOfFiles = 0;
finalLogFiles.forEach(logFile => {
const filePath = path.join(this.storagePath, logFile);
zip.addFile(filePath);
numberOfFiles++;
});
// Include accessories.json for diagnostics
if (fs.existsSync(this.storedAccessories_file)) {
zip.addFile(this.storedAccessories_file);
numberOfFiles++;
}
// Include unsupported.json for diagnostics
if (fs.existsSync(this.unsupported_file)) {
zip.addFile(this.unsupported_file);
numberOfFiles++;
}
this.pushEvent('diagnosticsProgress', { progress: 40, status: 'Checking archive content' });
if (numberOfFiles === 0) {
throw new Error('No diagnostic files were found');
}
try {
const now = new Date();
const timestamp = now.toISOString().replace(/[:T]/g, '-').replace(/\..+/, '');
this.diagnosticsZipFilePath = path.join(this.storagePath, `diagnostics-${timestamp}.zip`);
this.pushEvent('diagnosticsProgress', { progress: 45, status: `Compressing ${numberOfFiles} files` });
await zip.archive(this.diagnosticsZipFilePath);
this.pushEvent('diagnosticsProgress', { progress: 80, status: 'Reading content' });
const fileBuffer = fs.readFileSync(this.diagnosticsZipFilePath);
this.pushEvent('diagnosticsProgress', { progress: 90, status: 'Returning zip file' });
return { buffer: fileBuffer, filename: path.basename(this.diagnosticsZipFilePath) };
} catch (error) {
this.log.error('Error while generating diagnostics archive: ' + error);
throw error;
} finally {
this.removeDiagnosticsArchive();
}
}
removeDiagnosticsArchive() {
try {
if (fs.existsSync(this.diagnosticsZipFilePath)) {
fs.unlinkSync(this.diagnosticsZipFilePath);
}
return true;
} catch {
return false;
}
}
async cleanStorage() {
const preserved = new Set([
'accessories.json',
'persistent.json',
'eufy-security.log',
'eufy-lib.log',
'ffmpeg.log',
'configui-server.log',
'configui-lib.log',
]);
const files = await fs.promises.readdir(this.storagePath);
let deleted = 0;
for (const file of files) {
if (preserved.has(file)) continue;
const filePath = path.join(this.storagePath, file);
try {
await fs.promises.unlink(filePath);
deleted++;
this.log.debug(`Deleted: ${file}`);
} catch (error) {
this.log.warn(`Failed to delete ${file}: ${error}`);
}
}
this.log.info(`Cleaned ${deleted} file(s)`);
return { deleted };
}
async getSystemInfo() {
const os = await import('os');
let homebridgeVersion = 'unknown';
try {
const hbPkg = require('homebridge/package.json');
homebridgeVersion = hbPkg.version;
} catch {
// Homebridge package not resolvable from here
}
let deviceSummary = [];
try {
if (fs.existsSync(this.storedAccessories_file)) {
const storedData = JSON.parse(fs.readFileSync(this.storedAccessories_file, 'utf-8'));
if (storedData.stations) {
deviceSummary = storedData.stations.map(s => ({
name: s.displayName,
type: s.typename,
devices: (s.devices || []).map(d => ({
name: d.displayName,
type: d.typename,
})),
}));
}
}
} catch {
// ignore
}
this.log.debug('System info requested by UI');
return {
pluginVersion: LIB_VERSION,
eufyClientVersion: libVersion,
homebridgeVersion,
nodeVersion: process.version,
os: `${os.type()} ${os.release()} (${os.arch()})`,
devices: deviceSummary,
};
}
}
(() => new UiServer())();