homebridge-tsvesync
Version:
Homebridge plugin for VeSync devices including Levoit air purifiers, humidifiers, and Etekcity smart outlets
372 lines (329 loc) • 12.1 kB
text/typescript
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;
}
}