UNPKG

homebridge-tsvesync

Version:

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

372 lines (329 loc) 12.1 kB
import { CharacteristicValue, PlatformAccessory, Service } from 'homebridge'; import { TSVESyncPlatform } from '../platform'; import { DeviceCapabilities, VeSyncDeviceWithPower } from '../types/device.types'; import { RetryManager } from '../utils/retry'; import { LogContext, PluginLogger } from '../utils/logger'; export abstract class BaseAccessory { protected service!: Service; protected readonly platform: TSVESyncPlatform; protected readonly accessory: PlatformAccessory; protected readonly device: VeSyncDeviceWithPower; private readonly retryManager: RetryManager; private readonly logger: PluginLogger; private needsRetry = false; private isInitialized = false; private initializationPromise: Promise<void>; private initializationResolver!: () => void; private isInitializing = false; constructor( platform: TSVESyncPlatform, accessory: PlatformAccessory, device: VeSyncDeviceWithPower ) { 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 PluginLogger( this.platform.log, this.platform.config.debug ); this.retryManager = new 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 */ private getLogContext( operation?: string, characteristic?: string, value?: any ): Partial<LogContext> { return { deviceName: this.device.deviceName, deviceType: this.getDeviceType(), operation, characteristic, value, }; } /** * Set up the device-specific service */ protected abstract setupService(): void; /** * Update device-specific states */ protected abstract updateDeviceSpecificStates(details: any): Promise<void>; /** * Get device capabilities */ protected abstract getDeviceCapabilities(): DeviceCapabilities; /** * Get the device type for polling configuration */ protected getDeviceType(): string { // 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 */ private setupAccessory(): void { // 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 */ public async initialize(): Promise<void> { 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 as any; if (typeof deviceWithDetails.getDetails === 'function') { this.logger.debug('Refreshing device details during initialization', this.getLogContext()); await deviceWithDetails.getDetails(); this.logger.debug(`Device status after refresh: ${this.device.deviceStatus}`, this.getLogContext()); } } 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(); } } // Cache for device details to reduce API calls private deviceDetailsCache: any = null; private lastDetailsFetch = 0; private readonly CACHE_TTL = 60 * 1000; // 1 minute cache TTL /** * Sync the device state with VeSync */ public async syncDeviceState(): Promise<void> { 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 as any; 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 */ protected convertAirQualityToHomeKit(pm25: number): number { if (pm25 <= 12) return 1; // EXCELLENT if (pm25 <= 35) return 2; // GOOD if (pm25 <= 55) return 3; // FAIR if (pm25 <= 150) return 4; // INFERIOR return 5; // POOR } /** * Helper method to set up a characteristic with get/set handlers */ protected setupCharacteristic( characteristic: any, onGet?: () => Promise<CharacteristicValue>, onSet?: (value: CharacteristicValue) => Promise<void>, props?: Record<string, any>, service?: Service ): void { 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 } as LogContext); return value; } catch (error) { this.logger.error( 'Failed to get characteristic value', context, error as 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 as LogContext); } catch (error) { this.logger.error( 'Failed to set characteristic value', context, error as Error ); throw error; } }); } if (props) { char.setProps(props); } } /** * Helper method to update a characteristic value */ protected updateCharacteristicValue( characteristic: any, value: CharacteristicValue ): void { this.service.updateCharacteristic(characteristic, value); this.logger.stateChange({ ...this.getLogContext('update characteristic', characteristic.name, value), deviceName: this.device.deviceName, deviceType: this.getDeviceType(), } as LogContext); } /** * Persist device state to accessory context */ protected async persistDeviceState(key: string, value: unknown): Promise<void> { try { this.accessory.context.device = { ...this.accessory.context.device, details: { ...this.accessory.context.device?.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 as Error); } } /** * Handle device errors with appropriate recovery actions */ protected async handleDeviceError(message: string, error: Error | any): Promise<void> { 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 (error?.error?.code === 4041008 || error?.error?.msg?.includes('Device not found')) { this.logger.warn('Device not found', context, wrappedError); return; } // Handle quota limit error if (error?.error?.code === -16906086 || error?.error?.msg?.includes('quota')) { this.logger.warn(`API quota exceeded`, context, wrappedError); return; } // Handle rate limit error if (error?.message?.includes('rate limit') || error?.message?.includes('429')) { this.logger.warn(`Hit API rate limit (attempt ${retryCount})`, context, wrappedError); return; } // Handle network errors if (error?.code?.startsWith('ECONN') || error?.code === 'ETIMEDOUT') { this.logger.warn('Network error', context, wrappedError); return; } // Handle other errors this.logger.error(message, context, wrappedError); this.needsRetry = true; } }