UNPKG

homebridge-tsvesync

Version:

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

314 lines 14.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.BaseAccessory = void 0; const retry_1 = require("../utils/retry"); const logger_1 = require("../utils/logger"); class BaseAccessory { constructor(platform, accessory, device) { this.needsRetry = false; this.isInitialized = false; this.isInitializing = false; // Cache for device details to reduce API calls this.deviceDetailsCache = null; this.lastDetailsFetch = 0; this.CACHE_TTL = 60 * 1000; // 1 minute cache TTL this.platform = platform; this.accessory = accessory; this.device = device; // Create initialization promise this.initializationPromise = new Promise((resolve) => { this.initializationResolver = resolve; }); // Initialize managers this.logger = new logger_1.PluginLogger(this.platform.log, this.platform.config.debug); this.retryManager = new retry_1.RetryManager(this.platform.log, this.platform.config.retry); // Set up the accessory this.setupAccessory(); this.logger.debug('Accessory created', this.getLogContext()); } /** * Get base context for logging */ getLogContext(operation, characteristic, value) { return { deviceName: this.device.deviceName, deviceType: this.getDeviceType(), operation, characteristic, value, }; } /** * Get the device type for polling configuration */ getDeviceType() { // Default implementation - override in subclasses if needed if (this.device.deviceType.toLowerCase().includes('air')) { return 'airPurifier'; } else if (this.device.deviceType.toLowerCase().includes('humidifier')) { return 'humidifier'; } else if (this.device.deviceType.toLowerCase().includes('fan')) { return 'fan'; } else if (this.device.deviceType.toLowerCase().includes('bulb')) { return 'light'; } else if (this.device.deviceType.toLowerCase().includes('outlet')) { return 'outlet'; } return 'default'; } /** * Set up the accessory services */ setupAccessory() { // Set accessory information const infoService = this.accessory.getService(this.platform.Service.AccessoryInformation); if (infoService) { infoService .setCharacteristic(this.platform.Characteristic.Manufacturer, 'VeSync') .setCharacteristic(this.platform.Characteristic.Model, this.device.deviceType) .setCharacteristic(this.platform.Characteristic.SerialNumber, this.device.uuid); } // Set up device-specific service this.setupService(); } /** * Initialize the accessory */ async initialize() { if (this.isInitializing) { return; } this.isInitializing = true; try { // Ensure platform is ready before proceeding await this.platform.isReady(); // Try to refresh the device details first try { // Use type assertion to access potential getDetails method const deviceWithDetails = this.device; if (typeof deviceWithDetails.getDetails === 'function') { const now = Date.now(); const shouldUseCache = this.deviceDetailsCache !== null && (now - this.lastDetailsFetch < this.CACHE_TTL); if (shouldUseCache) { this.logger.debug('Using cached device details during initialization', this.getLogContext()); Object.assign(deviceWithDetails, this.deviceDetailsCache); } else { this.logger.debug('Refreshing device details during initialization', this.getLogContext()); const refreshResult = await deviceWithDetails.getDetails(); // Check if the API call was blocked due to quota if (refreshResult === null) { this.logger.warn('Device refresh skipped due to API quota limits during initialization', this.getLogContext()); } else { this.logger.debug(`Device status after refresh: ${this.device.deviceStatus}`, this.getLogContext()); this.deviceDetailsCache = { ...deviceWithDetails }; this.lastDetailsFetch = now; } } } } catch (refreshError) { this.logger.warn('Failed to refresh device details during initialization', this.getLogContext(), refreshError instanceof Error ? refreshError : new Error(String(refreshError))); } // Update states using device info we have await this.updateDeviceSpecificStates(this.device); this.isInitialized = true; this.logger.debug('Accessory initialized', this.getLogContext()); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); this.logger.error('Failed to initialize device state', this.getLogContext(), err); } finally { this.isInitializing = false; this.initializationResolver(); } } /** * Update the underlying device instance with the latest state from the VeSync client. * * The `tsvesync` library recreates device instances on each `client.update()`. Homebridge * accessories must keep stable handler instances, so we merge the latest device properties * into the existing instance and prime the cache so `syncDeviceState()` won't immediately * refetch details. */ applyUpdatedDeviceState(updatedDevice) { Object.assign(this.device, updatedDevice); this.deviceDetailsCache = { ...this.device }; this.lastDetailsFetch = Date.now(); } /** * Sync the device state with VeSync */ async syncDeviceState() { try { // Try to refresh the device details first, using cache when possible try { // Use type assertion to access potential getDetails method const deviceWithDetails = this.device; if (typeof deviceWithDetails.getDetails === 'function') { const now = Date.now(); const shouldUseCache = this.deviceDetailsCache !== null && (now - this.lastDetailsFetch < this.CACHE_TTL); if (shouldUseCache) { this.logger.debug('Using cached device details', this.getLogContext()); // Apply cached details to device if available if (this.deviceDetailsCache) { Object.assign(deviceWithDetails, this.deviceDetailsCache); } } else { this.logger.debug('Refreshing device details during sync', this.getLogContext()); const refreshResult = await deviceWithDetails.getDetails(); // Check if the API call was blocked due to quota if (refreshResult === null) { this.logger.warn('Device refresh skipped due to API quota limits', this.getLogContext()); // Continue with existing device state or cached state if available } else { this.logger.debug(`Device status after refresh: ${this.device.deviceStatus}`, this.getLogContext()); // Update cache with fresh data this.deviceDetailsCache = { ...deviceWithDetails }; this.lastDetailsFetch = now; } } } } catch (refreshError) { // Check if this is a quota error const errorMsg = String(refreshError); if (errorMsg.includes('quota') || errorMsg.includes('rate limit')) { this.logger.warn('API quota exceeded during device refresh', this.getLogContext()); } else { this.logger.warn('Failed to refresh device details during sync', this.getLogContext(), refreshError instanceof Error ? refreshError : new Error(String(refreshError))); } } // Update states using the device's internal state (even if refresh failed) await this.updateDeviceSpecificStates(this.device); } catch (error) { await this.handleDeviceError('Failed to sync device state', error instanceof Error ? error : new Error(String(error))); } } /** * Helper method to convert air quality values to HomeKit format */ convertAirQualityToHomeKit(pm25) { if (pm25 <= 12) return 1; // EXCELLENT if (pm25 <= 35) return 2; // GOOD if (pm25 <= 55) return 3; // FAIR return 4; // INFERIOR (VeSync exposes four levels) } /** * Helper method to set up a characteristic with get/set handlers */ setupCharacteristic(characteristic, onGet, onSet, props, service) { const targetService = service || this.service; const char = targetService.getCharacteristic(characteristic); if (onGet) { char.onGet(async () => { const context = this.getLogContext('get characteristic', characteristic.name); try { const value = await onGet(); this.logger.stateChange({ ...context, value }); return value; } catch (error) { this.logger.error('Failed to get characteristic value', context, error); throw error; } }); } if (onSet) { char.onSet(async (value) => { const context = this.getLogContext('set characteristic', characteristic.name, value); try { await onSet(value); this.logger.stateChange(context); } catch (error) { this.logger.error('Failed to set characteristic value', context, error); throw error; } }); } if (props) { char.setProps(props); } } /** * Helper method to update a characteristic value */ updateCharacteristicValue(characteristic, value) { this.service.updateCharacteristic(characteristic, value); this.logger.stateChange({ ...this.getLogContext('update characteristic', characteristic.name, value), deviceName: this.device.deviceName, deviceType: this.getDeviceType(), }); } /** * Persist device state to accessory context */ async persistDeviceState(key, value) { var _a; try { this.accessory.context.device = { ...this.accessory.context.device, details: { ...(_a = this.accessory.context.device) === null || _a === void 0 ? void 0 : _a.details, [key]: value } }; await this.platform.api.updatePlatformAccessories([this.accessory]); this.logger.debug(`Updated ${key} to ${value}`, this.getLogContext('persist state')); } catch (error) { this.handleDeviceError(`persist ${key} state`, error); } } /** * Handle device errors with appropriate recovery actions */ async handleDeviceError(message, error) { var _a, _b, _c, _d, _e, _f, _g, _h, _j; const context = this.getLogContext(); const retryCount = this.retryManager.getRetryCount(); const wrappedError = error instanceof Error ? error : new Error(JSON.stringify(error)); // Handle device not found error if (((_a = error === null || error === void 0 ? void 0 : error.error) === null || _a === void 0 ? void 0 : _a.code) === 4041008 || ((_c = (_b = error === null || error === void 0 ? void 0 : error.error) === null || _b === void 0 ? void 0 : _b.msg) === null || _c === void 0 ? void 0 : _c.includes('Device not found'))) { this.logger.warn('Device not found', context, wrappedError); return; } // Handle quota limit error if (((_d = error === null || error === void 0 ? void 0 : error.error) === null || _d === void 0 ? void 0 : _d.code) === -16906086 || ((_f = (_e = error === null || error === void 0 ? void 0 : error.error) === null || _e === void 0 ? void 0 : _e.msg) === null || _f === void 0 ? void 0 : _f.includes('quota'))) { this.logger.warn(`API quota exceeded`, context, wrappedError); return; } // Handle rate limit error if (((_g = error === null || error === void 0 ? void 0 : error.message) === null || _g === void 0 ? void 0 : _g.includes('rate limit')) || ((_h = error === null || error === void 0 ? void 0 : error.message) === null || _h === void 0 ? void 0 : _h.includes('429'))) { this.logger.warn(`Hit API rate limit (attempt ${retryCount})`, context, wrappedError); return; } // Handle network errors if (((_j = error === null || error === void 0 ? void 0 : error.code) === null || _j === void 0 ? void 0 : _j.startsWith('ECONN')) || (error === null || error === void 0 ? void 0 : error.code) === 'ETIMEDOUT') { this.logger.warn('Network error', context, wrappedError); return; } // Handle other errors this.logger.error(message, context, wrappedError); this.needsRetry = true; } } exports.BaseAccessory = BaseAccessory; //# sourceMappingURL=base.accessory.js.map