UNPKG

homebridge-tsvesync

Version:

Homebridge plugin for VeSync devices including Levoit air purifiers, humidifiers, and Etekcity smart outlets

696 lines 34 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.TSVESyncPlatform = void 0; const settings_1 = require("./settings"); const device_factory_1 = require("./utils/device-factory"); const logger_1 = require("./utils/logger"); const api_proxy_1 = require("./utils/api-proxy"); const session_store_1 = require("./utils/session-store"); /** * HomebridgePlatform * This class is the main constructor for your plugin, this is where you should * parse the user config and discover/register accessories with Homebridge. */ class TSVESyncPlatform { constructor(log, config, api) { this.log = log; this.config = config; this.api = api; this.Service = this.api.hap.Service; this.Characteristic = this.api.hap.Characteristic; // this is used to track restored cached accessories this.accessories = []; this.deviceAccessories = new Map(); // Track AQ sensor accessories separately this.aqSensorAccessories = new Map(); this.refreshInProgress = false; this.scheduledExpMs = null; this.refreshRemainingMs = null; this.lastLoginAttempt = new Date(0); this.loginBackoffTime = 10000; // Start with 10 seconds this.isInitialized = false; // VeSync JWT tokens are valid for 30 days (verified by decoding the JWT) // We'll refresh at 25 days to ensure we never hit expiration this.TOKEN_EXPIRY = 25 * 24 * 60 * 60 * 1000; // 25 days in milliseconds this.lastTokenRefresh = new Date(0); // Create initialization promise this.initializationPromise = new Promise((resolve) => { this.initializationResolver = resolve; }); // Get config values with defaults this.updateInterval = config.updateInterval || 30; this.debug = config.debug || false; // Initialize logger this.logger = new logger_1.PluginLogger(this.log, this.debug); // Validate configuration if (!config.username || !config.password) { this.logger.error('Missing required configuration. Please check your config.json'); return; } // Prepare session store this.sessionStore = new session_store_1.FileSessionStore(this.api.user.storagePath(), this.logger); // Initialize VeSync client with all configuration this.client = (0, api_proxy_1.createRateLimitedVeSync)(config.username, config.password, Intl.DateTimeFormat().resolvedOptions().timeZone, this.debug, true, // redact sensitive info config.apiUrl, this.logger, config.exclude, { countryCode: config.countryCode, quotaManagement: config.quotaManagement || { enabled: true } }, { store: this.sessionStore, onTokenChange: (s) => this.onTokenChange(s) }); this.logger.debug('Initialized platform with config:', { name: config.name, username: config.username, updateInterval: this.updateInterval, debug: this.debug, apiUrl: config.apiUrl, }); this.logger.info('Finished initializing platform:', this.config.name); // When this event is fired it means Homebridge has restored all cached accessories from disk. this.api.on('didFinishLaunching', async () => { this.logger.debug('Executed didFinishLaunching callback'); try { // Try to hydrate session from disk before any login const session = await this.sessionStore.load(); if (session) { try { if (session.username && session.username !== this.config.username) { this.logger.info('Found persisted session for a different account; ignoring persisted session.'); } else { this.hydrateSessionCompat(session); const ts = (0, session_store_1.decodeJwtTimestampsLocal)(session.token); // Use actual token issuance time if available to avoid overextending lifetime this.lastTokenRefresh = (ts === null || ts === void 0 ? void 0 : ts.iat) ? new Date(ts.iat * 1000) : new Date(); const expStr = (ts === null || ts === void 0 ? void 0 : ts.exp) ? new Date(ts.exp * 1000).toISOString() : 'unknown'; this.logger.info(`Reusing persisted VeSync session. Token exp: ${expStr}`); // Schedule a proactive refresh before expiry this.scheduleProactiveRefreshFromToken(session.token); } } catch (e) { this.logger.debug(`Failed to hydrate persisted session, will login fresh: ${(e === null || e === void 0 ? void 0 : e.message) || e}`); } } else { this.logger.debug('No persisted VeSync session available; will authenticate.'); } // Initialize platform await this.initializePlatform(); // Set up device update interval - default is 30 seconds, but we'll increase it to reduce API calls const effectiveUpdateInterval = Math.max(this.updateInterval, 120); // Minimum 2 minutes (120 seconds) this.deviceUpdateInterval = setInterval(() => { this.updateDeviceStates(); }, effectiveUpdateInterval * 1000); if (effectiveUpdateInterval > this.updateInterval) { this.logger.warn(`Increased update interval from ${this.updateInterval} to ${effectiveUpdateInterval} seconds to reduce API calls and prevent quota exhaustion`); } } catch (error) { this.logger.error('Failed to initialize platform:', error); // Ensure initialization is resolved even on error this.isInitialized = true; this.initializationResolver(); } }); // Clean up when shutting down this.api.on('shutdown', () => { if (this.deviceUpdateInterval) { clearInterval(this.deviceUpdateInterval); } if (this.refreshTimer) { clearTimeout(this.refreshTimer); } }); } /** * Check if platform is ready */ async isReady() { if (!this.isInitialized) { try { // Add a 30 second timeout to prevent infinite waiting const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Platform initialization timed out')), 30000); }); await Promise.race([this.initializationPromise, timeoutPromise]); } catch (error) { this.logger.error('Platform initialization failed:', error); // Force initialization state to true to prevent further waiting this.isInitialized = true; this.initializationResolver(); throw error; } } } /** * Initialize the platform */ async initializePlatform() { try { // If we don't have a token/account yet, perform a login once. Otherwise, // trust the persisted token and let the library re-login only if the API rejects it. if (!this.client.token || !this.client.accountId) { if (!await this.ensureLogin()) { throw new Error('Failed to login to VeSync API'); } } // Get devices from API (will re-login on 401/419 only) await this.client.update(); // Discover devices await this.discoverDevices(); // Mark as initialized before initializing accessories this.isInitialized = true; this.initializationResolver(); // Initialize all accessories const initPromises = Array.from(this.deviceAccessories.entries()).map(([uuid, accessory]) => { var _a; const deviceName = ((_a = this.accessories.find(acc => acc.UUID === uuid)) === null || _a === void 0 ? void 0 : _a.displayName) || uuid; return accessory.initialize().catch(error => { this.logger.error(`Failed to initialize accessory ${deviceName}:`, error); }); }); await Promise.all(initPromises); } catch (error) { this.logger.error('Failed to initialize platform:', error); // Still resolve the promise to allow retries during polling this.isInitialized = true; this.initializationResolver(); throw error; } } /** * This function is invoked when homebridge restores cached accessories from disk at startup. */ configureAccessory(accessory) { this.logger.info('Loading accessory from cache:', accessory.displayName); this.accessories.push(accessory); } /** * Get all devices from all categories */ getAllDevices() { return [ ...this.client.fans, ...this.client.outlets, ...this.client.switches, ...this.client.bulbs, ]; } /** * Create a serializable device context */ createDeviceContext(device) { return { cid: device.cid, deviceName: device.deviceName.trim(), deviceStatus: device.deviceStatus, deviceType: device.deviceType, deviceRegion: device.deviceRegion, uuid: device.uuid, configModule: device.configModule, macId: device.macId, deviceCategory: device.deviceCategory, connectionStatus: device.connectionStatus, details: device.details || {}, config: device.config || {} }; } /** * Ensure client is logged in, but avoid unnecessary logins */ async ensureLogin(forceLogin = false) { var _a, _b, _c; // Check if token needs refresh const timeSinceLastRefresh = Date.now() - this.lastTokenRefresh.getTime(); if (!forceLogin && timeSinceLastRefresh < this.TOKEN_EXPIRY) { return true; // Token is still valid } let isLoggedIn = false; while (!isLoggedIn) { // Keep trying until successful try { // Check if we need to wait for backoff const timeSinceLastAttempt = Date.now() - this.lastLoginAttempt.getTime(); if (timeSinceLastAttempt < this.loginBackoffTime) { const waitTime = this.loginBackoffTime - timeSinceLastAttempt; this.logger.debug(`Waiting ${waitTime}ms before next login attempt (backoff)`); await new Promise(resolve => setTimeout(resolve, waitTime)); } // Need to login again this.logger.debug(forceLogin ? 'Forcing new login to VeSync API' : 'Refreshing VeSync API token'); this.lastLoginAttempt = new Date(); const loginResult = await this.client.login(); if (!loginResult) { this.logger.error('Login failed - invalid credentials or API error'); this.loginBackoffTime = Math.min(this.loginBackoffTime * 2, 300000); continue; // Try again after backoff } // Reset backoff and update token refresh time on successful login this.loginBackoffTime = 10000; this.lastTokenRefresh = new Date(); // Best-effort: persist the fresh session immediately in case callbacks fail try { const token = this.client.token; const accountId = this.client.accountId; const region = this.client.region; const apiBaseUrl = this.client.apiBaseUrl; if (token && accountId && region && apiBaseUrl) { const ts = (0, session_store_1.decodeJwtTimestampsLocal)(token); await this.sessionStore.save({ token, accountId, region, apiBaseUrl, issuedAt: (_a = ts === null || ts === void 0 ? void 0 : ts.iat) !== null && _a !== void 0 ? _a : null, expiresAt: (_b = ts === null || ts === void 0 ? void 0 : ts.exp) !== null && _b !== void 0 ? _b : null, lastValidatedAt: Date.now(), username: this.config.username, }); } } catch ( /* ignore */_d) { /* ignore */ } isLoggedIn = true; return true; } catch (error) { // Handle specific errors const errorObj = error; const errorMsg = ((_c = errorObj === null || errorObj === void 0 ? void 0 : errorObj.error) === null || _c === void 0 ? void 0 : _c.msg) || (errorObj === null || errorObj === void 0 ? void 0 : errorObj.msg) || String(error); if (errorMsg.includes('Not logged in')) { this.logger.debug('Session expired, forcing new login'); this.loginBackoffTime = Math.min(this.loginBackoffTime, 5000); continue; // Try again after backoff } // Increase backoff time exponentially, max 5 minutes this.loginBackoffTime = Math.min(this.loginBackoffTime * 2, 300000); this.logger.error('Login error:', error); continue; // Try again after backoff } } return true; // This line will never be reached but TypeScript needs it } /** * Update device states periodically */ async updateDeviceStates() { if (this.refreshInProgress) { return; } this.refreshInProgress = true; try { await this.discoverDevices(); const syncTasks = Array.from(this.deviceAccessories.values()).map(async (accessory) => { try { await accessory.syncDeviceState(); } catch (error) { this.logger.warn('Failed to sync device state during scheduled refresh', error); } }); await Promise.all(syncTasks); } finally { this.refreshInProgress = false; } } /** * Handle token updates from the library */ onTokenChange(session) { if (!(session === null || session === void 0 ? void 0 : session.token)) return; this.scheduleProactiveRefreshFromToken(session.token); // NOTE: The library already saved the session via sessionStore.save() // We should NOT save again here as it creates a race condition and corrupts the file // The username field is added during initial login in onSuccessfulLogin() } /** * Backward-compatible session hydration when using older tsvesync versions */ hydrateSessionCompat(session) { var _a; const client = this.client; if (typeof client.hydrateSession === 'function') { client.hydrateSession(session); return; } // Fallback: set core fields directly client.token = session.token; client.accountId = session.accountId; client.countryCode = (_a = session.countryCode) !== null && _a !== void 0 ? _a : null; if (session.apiBaseUrl) { client.apiBaseUrl = session.apiBaseUrl; } if (session.region) { try { client.region = session.region; } catch ( /* ignore */_b) { /* ignore */ } } client.enabled = true; } /** * Schedule a proactive token refresh before JWT expiry */ scheduleProactiveRefreshFromToken(token) { try { const ts = (0, session_store_1.decodeJwtTimestampsLocal)(token); if (!(ts === null || ts === void 0 ? void 0 : ts.exp)) { return; // Cannot schedule without exp } const now = Date.now(); const expMs = ts.exp * 1000; const msToExpiry = expMs - now; // If we already have a timer for this exact token expiration, skip if (this.scheduledExpMs === expMs && this.refreshTimer) { return; } if (msToExpiry <= 0) { // Already expired; trigger immediate login in background void this.ensureLogin(true); return; } // Schedule policy to prevent thrash and avoid frequent logins: // - If >7d left: refresh 5d before expiry // - If 1–7d left: refresh 12h before expiry // - If 1–24h left: refresh 1h before expiry // - If <1h left: do not proactively refresh; rely on library's 401-triggered re-login const ONE_HOUR = 60 * 60 * 1000; const TWELVE_HOURS = 12 * ONE_HOUR; const FIVE_DAYS = 5 * 24 * ONE_HOUR; const SEVEN_DAYS = 7 * 24 * ONE_HOUR; let refreshIn; if (msToExpiry > SEVEN_DAYS) { refreshIn = msToExpiry - FIVE_DAYS; } else if (msToExpiry > 24 * ONE_HOUR) { refreshIn = msToExpiry - TWELVE_HOURS; } else if (msToExpiry > ONE_HOUR) { refreshIn = msToExpiry - ONE_HOUR; } else { // Too close to expiry; avoid hammering login — let 401 path handle it this.logger.debug('Token near expiry (<1h). Skipping proactive refresh; relying on auto re-login.'); return; } // Safety floor: never schedule earlier than 30 minutes from now refreshIn = Math.max(refreshIn, 30 * 60 * 1000); if (this.refreshTimer) { clearTimeout(this.refreshTimer); } this.scheduledExpMs = expMs; // Handle Node.js setTimeout max delay (~24.8 days). Chain timers when needed. const MAX_DELAY = 0x7fffffff; // 2,147,483,647 ms if (refreshIn > MAX_DELAY) { this.refreshRemainingMs = refreshIn - MAX_DELAY; this.logger.debug('Proactive refresh scheduled beyond setTimeout max; chaining timers.'); this.refreshTimer = setTimeout(() => this.chainRefreshTimer(), MAX_DELAY); } else { this.refreshRemainingMs = 0; this.refreshTimer = setTimeout(async () => { if (this.refreshInProgress) { this.logger.debug('Proactive refresh already in progress; skipping.'); return; } this.refreshInProgress = true; this.logger.debug('Proactively refreshing VeSync session before token expiry'); try { await this.ensureLogin(true); } finally { this.refreshInProgress = false; } }, refreshIn); } const hours = Math.round(refreshIn / (60 * 60 * 1000)); this.logger.debug(`Scheduled proactive token refresh in ~${hours}h`); } catch (e) { // Best-effort scheduling; ignore errors } } chainRefreshTimer() { if (!this.refreshRemainingMs || this.refreshRemainingMs <= 0) { // Final hop: trigger refresh now if (this.refreshInProgress) { this.logger.debug('Proactive refresh already in progress; skipping.'); return; } this.refreshInProgress = true; this.logger.debug('Proactively refreshing VeSync session before token expiry'); void this.ensureLogin(true).finally(() => { this.refreshInProgress = false; }); return; } const MAX_DELAY = 0x7fffffff; const hop = Math.min(this.refreshRemainingMs, MAX_DELAY); this.refreshRemainingMs -= hop; this.refreshTimer = setTimeout(() => this.chainRefreshTimer(), hop); } /** * Check if a device should be excluded based on configuration */ shouldExcludeDevice(device) { var _a, _b, _c, _d, _e; const exclude = this.config.exclude; if (!exclude) { return false; } // Check device type if ((_a = exclude.type) === null || _a === void 0 ? void 0 : _a.includes(device.deviceType.toLowerCase())) { this.logger.debug(`Excluding device ${device.deviceName} by type: ${device.deviceType}`); return true; } // Check device model if ((_b = exclude.model) === null || _b === void 0 ? void 0 : _b.some(model => device.deviceType.toUpperCase().includes(model.toUpperCase()))) { this.logger.debug(`Excluding device ${device.deviceName} by model: ${device.deviceType}`); return true; } // Check exact name match if ((_c = exclude.name) === null || _c === void 0 ? void 0 : _c.includes(device.deviceName.trim())) { this.logger.debug(`Excluding device ${device.deviceName} by exact name match`); return true; } // Check name patterns if (exclude.namePattern) { for (const pattern of exclude.namePattern) { try { const regex = new RegExp(pattern); if (regex.test(device.deviceName.trim())) { this.logger.debug(`Excluding device ${device.deviceName} by name pattern: ${pattern}`); return true; } } catch (error) { this.logger.warn(`Invalid regex pattern in exclude config: ${pattern}`); } } } // Check device ID (cid or uuid) if (((_d = exclude.id) === null || _d === void 0 ? void 0 : _d.includes(device.cid)) || ((_e = exclude.id) === null || _e === void 0 ? void 0 : _e.includes(device.uuid))) { this.logger.debug(`Excluding device ${device.deviceName} by ID: ${device.cid}/${device.uuid}`); return true; } return false; } /** * This function discovers and registers your devices as accessories */ async discoverDevices() { this.logger.debug('Discovering devices'); try { // Do not force login; rely on library to re-login only if needed let retryCount = 0; let success = false; // Keep retrying API calls while (!success) { try { // Update device data from API await this.client.update(); success = true; } catch (error) { retryCount++; const backoffTime = Math.min(10000 * Math.pow(2, retryCount), 300000); this.logger.warn(`API call failed, retry attempt ${retryCount}. Waiting ${backoffTime / 1000} seconds...`); await new Promise(resolve => setTimeout(resolve, backoffTime)); // Try to ensure we're still logged in before next attempt if (!await this.ensureLogin()) { continue; } } } // Get all devices const devices = this.getAllDevices().filter(device => !this.shouldExcludeDevice(device)); // Update quota manager with device count if (typeof this.client.updateQuotaDeviceCount === 'function') { this.client.updateQuotaDeviceCount(devices.length); this.logger.debug(`Updated quota manager with ${devices.length} devices`); } // Track processed devices for cleanup const processedDeviceUUIDs = new Set(); // Loop over the discovered devices and register each one for (const device of devices) { // Generate a unique id for the accessory const uuid = this.generateDeviceUUID(device); processedDeviceUUIDs.add(uuid); // Check if an accessory already exists let accessory = this.accessories.find(acc => acc.UUID === uuid); if (accessory) { // Accessory already exists this.logger.debug('Restoring existing accessory from cache:', device.deviceName); // Update the accessory context accessory.context.device = this.createDeviceContext(device); // Update accessory this.api.updatePlatformAccessories([accessory]); } else { // Create a new accessory this.logger.info('Adding new accessory:', device.deviceName); // Create the accessory accessory = new this.api.platformAccessory(device.deviceName, uuid, device_factory_1.DeviceFactory.getAccessoryCategory(device.deviceType)); // Store device information in context accessory.context.device = this.createDeviceContext(device); } // Create the accessory handler const deviceAccessory = device_factory_1.DeviceFactory.createAccessory(this, accessory, device); this.deviceAccessories.set(uuid, deviceAccessory); // Check if device needs a separate AQ sensor accessory if (this.deviceHasAirQuality(device) && device_factory_1.DeviceFactory.isAirPurifier(device.deviceType)) { const aqUuid = this.generateDeviceUUID(device, '-AQ'); processedDeviceUUIDs.add(aqUuid); // Check if AQ sensor accessory already exists let aqAccessory = this.accessories.find(acc => acc.UUID === aqUuid); if (aqAccessory) { // AQ sensor accessory already exists this.logger.debug('Restoring existing AQ sensor from cache:', device.deviceName + ' AQ'); // Update the accessory context aqAccessory.context.device = this.createDeviceContext(device); aqAccessory.context.isAQSensor = true; aqAccessory.context.parentUUID = uuid; // Store parent relationship // Update accessory this.api.updatePlatformAccessories([aqAccessory]); } else { // Create a new AQ sensor accessory this.logger.debug('Adding new AQ sensor accessory:', device.deviceName + ' AQ'); // Create the AQ sensor accessory with SENSOR category aqAccessory = new this.api.platformAccessory(device.deviceName + ' Air Quality', aqUuid, 10 /* this.api.hap.Categories.SENSOR */); // Store device information in context aqAccessory.context.device = this.createDeviceContext(device); aqAccessory.context.isAQSensor = true; aqAccessory.context.parentUUID = uuid; // Store parent relationship } // Store the AQ sensor accessory this.aqSensorAccessories.set(aqUuid, aqAccessory); // Create the AQ sensor accessory handler const aqSensorAccessory = device_factory_1.DeviceFactory.createAQSensorAccessory(this, aqAccessory, device); if (aqSensorAccessory) { this.deviceAccessories.set(aqUuid, aqSensorAccessory); // Register new AQ sensor accessory if (!this.accessories.find(acc => acc.UUID === aqUuid)) { this.api.registerPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [aqAccessory]); this.accessories.push(aqAccessory); } } else { this.logger.warn(`${device.deviceName}: Failed to create AQ sensor accessory handler - DeviceFactory.createAQSensorAccessory returned null`); } } // Register new accessories if (!this.accessories.find(acc => acc.UUID === uuid)) { this.api.registerPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]); this.accessories.push(accessory); } // Try to update device state with retries let stateRetryCount = 0; let stateSuccess = false; while (!stateSuccess && stateRetryCount < 3) { // Limit retries to 3 attempts try { await deviceAccessory.syncDeviceState(); stateSuccess = true; } catch (error) { stateRetryCount++; // Only retry if we haven't hit the limit if (stateRetryCount < 3) { const backoffTime = Math.min(5000 * Math.pow(2, stateRetryCount), 30000); this.logger.warn(`Failed to sync device state for ${device.deviceName}, retry attempt ${stateRetryCount}. Waiting ${backoffTime / 1000} seconds...`); await new Promise(resolve => setTimeout(resolve, backoffTime)); // Try to ensure we're still logged in before next attempt if (!await this.ensureLogin()) { continue; } } else { this.logger.error(`Failed to sync device state for ${device.deviceName} after ${stateRetryCount} attempts. Skipping.`); // Continue with other devices even if this one fails break; } } } } // Remove platform accessories that no longer exist or are now excluded this.accessories .filter(accessory => !processedDeviceUUIDs.has(accessory.UUID)) .forEach(accessory => { this.logger.info('Removing existing accessory:', accessory.displayName); try { // Remove from platform's accessories array first const index = this.accessories.indexOf(accessory); if (index > -1) { this.accessories.splice(index, 1); } // Then try to unregister from the bridge this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]); } catch (error) { this.logger.debug(`Failed to unregister accessory ${accessory.displayName}, it may have already been removed:`, error); } // Always clean up the device accessory handler this.deviceAccessories.delete(accessory.UUID); }); } catch (error) { this.logger.error('Failed to discover devices:', error); } } /** * Generate a consistent UUID for a device * @param device The device to generate a UUID for * @param suffix Optional suffix for accessory type (e.g., '-AQ' for air quality sensor) * @returns The generated UUID string */ generateDeviceUUID(device, suffix = '') { let id = device.cid; if (device.isSubDevice && device.subDeviceNo !== undefined) { id = `${device.cid}_${device.subDeviceNo}`; } return this.api.hap.uuid.generate(id + suffix); } /** * Check if a device has air quality sensor * @param device The device to check * @returns true if device has AQ sensor */ deviceHasAirQuality(device) { // Use the device's native feature detection if available if (typeof device.hasFeature === 'function') { return device.hasFeature('air_quality'); } // Fallback to device type checking for older devices const deviceType = device.deviceType || ''; // Core300S, Core400S, Core600S have AQ sensors // Core200S does NOT have AQ sensor return (deviceType.includes('Core300S') || deviceType.includes('Core400S') || deviceType.includes('Core600S') || (deviceType.includes('LAP-') && !deviceType.includes('LAP-EL')) || deviceType.includes('LV-PUR131S')); } } exports.TSVESyncPlatform = TSVESyncPlatform; //# sourceMappingURL=platform.js.map