homebridge-tsvesync
Version:
Homebridge plugin for VeSync devices including Levoit air purifiers, humidifiers, and Etekcity smart outlets
723 lines (615 loc) • 26.6 kB
text/typescript
import { CharacteristicValue, PlatformAccessory, Service } from 'homebridge';
import { BaseAccessory } from './base.accessory';
import { TSVESyncPlatform } from '../platform';
import { DeviceCapabilities, VeSyncAirPurifier } from '../types/device.types';
// Extended interface to include device-specific methods and properties
interface ExtendedVeSyncAirPurifier extends VeSyncAirPurifier {
deviceType: string;
// Override setMode from base interface to make it more specific
setMode(mode: string): Promise<boolean>;
// Add additional methods
autoMode?(): Promise<boolean>;
manualMode?(): Promise<boolean>;
sleepMode?(): Promise<boolean>;
setDisplay?(on: boolean): Promise<boolean>;
turnOnDisplay?(): Promise<boolean>;
turnOffDisplay?(): Promise<boolean>;
setChildLock?(enabled: boolean): Promise<boolean>;
setOscillation?(enabled: boolean): Promise<boolean>;
hasFeature?(feature: string): boolean;
isFeatureSupportedInCurrentMode?(feature: string): boolean;
getMaxFanSpeed?(): number;
details?: {
filter_life?: number;
child_lock?: boolean;
air_quality_value?: number;
air_quality?: string;
screen_status?: 'on' | 'off';
pm25?: number;
};
}
export class AirPurifierAccessory extends BaseAccessory {
private readonly capabilities: DeviceCapabilities;
protected readonly device: VeSyncAirPurifier;
private isAirBypassDevice: boolean;
private isAirBaseV2Device: boolean;
private isAir131Device: boolean;
private airQualityService?: Service;
private lastSetSpeed: number = 0; // Track the last speed we set
private lastSetPercentage: number = 0; // Track the last percentage we set
private skipNextUpdate: boolean = false; // Flag to skip the next update
constructor(
platform: TSVESyncPlatform,
accessory: PlatformAccessory,
device: VeSyncAirPurifier
) {
super(platform, accessory, device);
this.device = device;
this.capabilities = this.getDeviceCapabilities();
// Detect device class based on model number patterns
const extendedDevice = this.device as ExtendedVeSyncAirPurifier;
const deviceType = extendedDevice.deviceType || '';
// AirBypass Devices: Core series, LAP-C series, Vital series
this.isAirBypassDevice = deviceType.includes('CORE') ||
deviceType.startsWith('LAP-C') ||
deviceType.includes('VITAL');
// AirBaseV2 Devices: LAP-V series, LAP-EL series, EverestAir series
this.isAirBaseV2Device = deviceType.startsWith('LAP-V') ||
deviceType.startsWith('LAP-EL') ||
deviceType.includes('EVERESTAIR');
// Air131 Devices: LV-PUR131S, LV-RH131S
this.isAir131Device = deviceType.startsWith('LV-');
// Log device class detection
if (this.isAirBypassDevice) {
this.platform.log.debug(`Detected AirBypass device: ${this.device.deviceName} (${deviceType})`);
} else if (this.isAirBaseV2Device) {
this.platform.log.debug(`Detected AirBaseV2 device: ${this.device.deviceName} (${deviceType})`);
} else if (this.isAir131Device) {
this.platform.log.debug(`Detected Air131 device: ${this.device.deviceName} (${deviceType})`);
} else {
this.platform.log.debug(`Unknown air purifier type: ${this.device.deviceName} (${deviceType})`);
}
}
/**
* Feature detection system
*/
private hasFeature(feature: string): boolean {
const extendedDevice = this.device as unknown as ExtendedVeSyncAirPurifier;
// Use device's native hasFeature method if available
if (typeof extendedDevice.hasFeature === 'function') {
return extendedDevice.hasFeature(feature);
}
// Fallback feature detection based on device type
switch (feature) {
case 'air_quality':
// Explicitly disable air quality features
return false;
case 'child_lock':
// Explicitly disable child lock features
return false;
case 'display':
// Explicitly disable display control features
return false;
case 'filter_life':
// Explicitly disable filter life reporting
return false;
case 'fan_speed':
return true;
default:
return false;
}
}
/**
* Check if a feature is supported in the current device mode
*/
private isFeatureSupportedInCurrentMode(feature: string): boolean {
const extendedDevice = this.device as unknown as ExtendedVeSyncAirPurifier;
// Use device's native method if available
if (typeof extendedDevice.isFeatureSupportedInCurrentMode === 'function') {
return extendedDevice.isFeatureSupportedInCurrentMode(feature);
}
// Fallback logic
const mode = extendedDevice.mode || 'manual';
// Air131 only supports fan speed in manual mode
if (feature === 'fan_speed' && this.isAir131Device && mode !== 'manual') {
return false;
}
return true;
}
/**
* Get the maximum fan speed for the device
*/
private getMaxFanSpeed(): number {
const extendedDevice = this.device as unknown as ExtendedVeSyncAirPurifier;
// Use device's native method if available
if (typeof extendedDevice.getMaxFanSpeed === 'function') {
const maxSpeed = extendedDevice.getMaxFanSpeed();
if (typeof maxSpeed === 'number' && maxSpeed > 0) {
return maxSpeed;
}
}
// Use maxSpeed property if available
if (typeof extendedDevice.maxSpeed === 'number' && extendedDevice.maxSpeed > 0) {
return extendedDevice.maxSpeed;
}
// Fallback logic based on device type
if (this.isAir131Device) {
return 3; // LV-series devices have 3 speed levels
} else if (this.isAirBypassDevice) {
return 3; // All Core series have 3 speed levels
} else if (this.isAirBaseV2Device) {
if (this.device.deviceType.includes('LAP-EL551S')) {
return 3; // LAP-EL551S has 3 speed levels
} else {
return 4; // LAP-V series have 4 speed levels
}
}
// Default to 3 for unknown devices
return 3;
}
/**
* Convert device speed to percentage
*/
private speedToPercentage(speed: number): number {
const maxSpeed = this.getMaxFanSpeed();
// Ensure we have valid numbers
if (maxSpeed <= 0 || typeof speed !== 'number' || speed <= 0) {
return 0;
}
// For devices with 3 speed levels
if (maxSpeed === 3) {
switch (speed) {
case 1: return 33; // Low -> 33%
case 2: return 67; // Medium -> 67%
case 3: return 100; // High -> 100%
default: return 0;
}
}
// For devices with 4 speed levels
else if (maxSpeed === 4) {
switch (speed) {
case 1: return 25; // Low -> 25%
case 2: return 50; // Medium-Low -> 50%
case 3: return 75; // Medium-High -> 75%
case 4: return 100; // High -> 100%
default: return 0;
}
}
// Default calculation
return Math.min(100, Math.max(0, Math.round((speed / maxSpeed) * 100)));
}
/**
* Convert percentage to device speed
*/
private percentageToSpeed(percentage: number): number {
const maxSpeed = this.getMaxFanSpeed();
// Ensure we have valid numbers
if (maxSpeed <= 0 || typeof percentage !== 'number') {
return 1; // Default to lowest speed
}
// Ensure percentage is between 0 and 100
percentage = Math.min(100, Math.max(0, percentage));
// For devices with 3 speed levels
if (maxSpeed === 3) {
if (percentage <= 33) {
return 1; // Low
} else if (percentage <= 67) {
return 2; // Medium
} else {
return 3; // High
}
}
// For devices with 4 speed levels
else if (maxSpeed === 4) {
if (percentage <= 25) {
return 1; // Low
} else if (percentage <= 50) {
return 2; // Medium-Low
} else if (percentage <= 75) {
return 3; // Medium-High
} else {
return 4; // High
}
}
// Default calculation
return Math.max(1, Math.min(Math.round((percentage / 100) * maxSpeed), maxSpeed));
}
/**
* Set up the air purifier service
*/
protected setupService(): void {
// Get or create the air purifier service
this.service = this.accessory.getService(this.platform.Service.AirPurifier) ||
this.accessory.addService(this.platform.Service.AirPurifier);
// Set up required characteristics
this.setupCharacteristic(
this.platform.Characteristic.Active,
this.getActive.bind(this),
this.setActive.bind(this)
);
// Set up target state characteristic for mode mapping
const targetStateChar = this.service.getCharacteristic(this.platform.Characteristic.TargetAirPurifierState);
// For Core200S, only allow manual mode
if (this.device.deviceType.includes('Core200S')) {
targetStateChar.setProps({
validValues: [0] // MANUAL only
});
} else if (this.device.deviceType.includes('Core300S')) {
// Explicitly ensure Core300S has both AUTO and MANUAL modes
// Force the mode toggle to be visible for Core300S devices
this.platform.log.debug(`Explicitly enabling mode toggle for Core300S device: ${this.device.deviceName}`);
// First remove any existing characteristic to ensure clean setup
if (this.service.testCharacteristic(this.platform.Characteristic.TargetAirPurifierState)) {
this.service.removeCharacteristic(
this.service.getCharacteristic(this.platform.Characteristic.TargetAirPurifierState)
);
}
// Re-add the characteristic with proper properties
const newTargetStateChar = this.service.addCharacteristic(this.platform.Characteristic.TargetAirPurifierState);
newTargetStateChar.setProps({
validValues: [0, 1], // MANUAL and AUTO
perms: [this.platform.Characteristic.Perms.PAIRED_READ, this.platform.Characteristic.Perms.PAIRED_WRITE, this.platform.Characteristic.Perms.NOTIFY]
});
// Set up the characteristic handlers
newTargetStateChar.onGet(async () => {
try {
const value = await this.getTargetState();
return value;
} catch (error) {
this.platform.log.error(`Error getting target state: ${error}`);
throw error;
}
});
newTargetStateChar.onSet(async (value) => {
try {
await this.setTargetState(value);
} catch (error) {
this.platform.log.error(`Error setting target state: ${error}`);
throw error;
}
});
} else {
targetStateChar.setProps({
validValues: [0, 1] // MANUAL and AUTO
});
// Set up the characteristic handlers for non-Core300S devices
this.setupCharacteristic(
this.platform.Characteristic.TargetAirPurifierState,
this.getTargetState.bind(this),
this.setTargetState.bind(this)
);
}
// Set up current state
this.setupCharacteristic(
this.platform.Characteristic.CurrentAirPurifierState,
this.getCurrentState.bind(this)
);
// Set up speed control with special handling for Core200S and Core300S
if (this.device.deviceType.includes('Core200S') || this.device.deviceType.includes('Core300S')) {
// For Core200S and Core300S, set up rotation speed characteristic manually
this.platform.log.debug(`Explicitly setting up rotation speed for ${this.device.deviceName}`);
// First remove any existing characteristic to ensure clean setup
if (this.service.testCharacteristic(this.platform.Characteristic.RotationSpeed)) {
this.service.removeCharacteristic(
this.service.getCharacteristic(this.platform.Characteristic.RotationSpeed)
);
}
// Re-add the characteristic with proper properties
const rotationSpeedChar = this.service.addCharacteristic(this.platform.Characteristic.RotationSpeed);
rotationSpeedChar.setProps({
minValue: 0,
maxValue: 100,
minStep: 1,
perms: [this.platform.Characteristic.Perms.PAIRED_READ, this.platform.Characteristic.Perms.PAIRED_WRITE, this.platform.Characteristic.Perms.NOTIFY]
});
// Set up the characteristic handlers
rotationSpeedChar.onGet(async () => {
try {
const value = await this.getRotationSpeed();
this.platform.log.debug(`${this.device.deviceName} getRotationSpeed returned: ${value}`);
return value;
} catch (error) {
this.platform.log.error(`Error getting rotation speed: ${error}`);
throw error;
}
});
rotationSpeedChar.onSet(async (value) => {
try {
this.platform.log.debug(`${this.device.deviceName} setRotationSpeed called with: ${value}`);
await this.setRotationSpeed(value);
} catch (error) {
this.platform.log.error(`Error setting rotation speed: ${error}`);
throw error;
}
});
} else {
// For other devices, use the standard setup
this.setupCharacteristic(
this.platform.Characteristic.RotationSpeed,
this.getRotationSpeed.bind(this),
this.setRotationSpeed.bind(this)
);
}
// Add Name characteristic
this.setupCharacteristic(
this.platform.Characteristic.Name,
async () => this.device.deviceName
);
// Air quality sensor service has been disabled for all devices
}
// Air quality sensor service has been removed
/**
* Get current state (INACTIVE = 0, IDLE = 1, PURIFYING_AIR = 2)
*/
private async getCurrentState(): Promise<CharacteristicValue> {
return this.device.deviceStatus === 'on' ? 2 : 0;
}
/**
* Get device capabilities
*/
protected getDeviceCapabilities(): DeviceCapabilities {
return {
hasSpeed: true,
hasAirQuality: this.hasFeature('air_quality'),
hasChildLock: false, // Explicitly disable child lock
hasBrightness: false,
hasColorTemp: false,
hasColor: false,
hasHumidity: false,
hasWaterLevel: false,
hasSwingMode: false,
};
}
/**
* Update device states based on the latest details
*/
protected async updateDeviceSpecificStates(details: any): Promise<void> {
// Update power state
const isOn = details.enabled || details.deviceStatus === 'on';
this.service.updateCharacteristic(this.platform.Characteristic.Active, isOn ? 1 : 0);
// Update current state (INACTIVE = 0, IDLE = 1, PURIFYING_AIR = 2)
this.service.updateCharacteristic(
this.platform.Characteristic.CurrentAirPurifierState,
isOn ? 2 : 0
);
// Get the extended device to access mode information
const extendedDevice = this.device as ExtendedVeSyncAirPurifier;
// Update target state (MANUAL = 0, AUTO = 1)
// Special handling for Core200S
let targetState = 0; // Default to MANUAL
if (this.device.deviceType.includes('Core200S')) {
// Core200S always reports MANUAL mode
targetState = 0; // MANUAL
} else if (extendedDevice.mode) {
// For other devices, including Core300S, use the reported mode
targetState = extendedDevice.mode === 'auto' ? 1 : 0;
}
this.service.updateCharacteristic(
this.platform.Characteristic.TargetAirPurifierState,
targetState
);
// Update rotation speed
if (isOn && details.speed !== undefined && details.speed !== null) {
// Check if we should skip this update
if (this.skipNextUpdate) {
this.platform.log.debug(`Skipping update for ${this.device.deviceName} as requested`);
this.skipNextUpdate = false;
return;
}
// If we've recently set the speed, use the percentage we calculated
if (this.lastSetSpeed > 0 && this.lastSetPercentage > 0) {
this.platform.log.debug(`Using last set percentage for ${this.device.deviceName}: speed=${this.lastSetSpeed}, percentage=${this.lastSetPercentage}`);
// Only update if the device speed matches what we set
if (this.lastSetSpeed === details.speed) {
this.platform.log.debug(`Device speed matches last set speed: ${details.speed}`);
// Force update the characteristic to our last set percentage
this.service.updateCharacteristic(
this.platform.Characteristic.RotationSpeed,
this.lastSetPercentage
);
} else {
// Device speed doesn't match what we set, so convert from device speed
const percentage = this.speedToPercentage(details.speed);
this.platform.log.debug(`Device speed doesn't match last set speed. Device: ${details.speed}, Last set: ${this.lastSetSpeed}, Calculated percentage: ${percentage}`);
// Update our tracking variables
this.lastSetSpeed = details.speed;
this.lastSetPercentage = percentage;
// Update the characteristic
this.service.updateCharacteristic(
this.platform.Characteristic.RotationSpeed,
percentage
);
}
} else {
// No recent set speed, so convert from device speed
const percentage = this.speedToPercentage(details.speed);
this.platform.log.debug(`No recent set speed for ${this.device.deviceName}. Device speed: ${details.speed}, Calculated percentage: ${percentage}`);
// Update the characteristic
this.service.updateCharacteristic(
this.platform.Characteristic.RotationSpeed,
percentage
);
}
} else {
this.service.updateCharacteristic(
this.platform.Characteristic.RotationSpeed,
0
);
// Reset tracking variables when device is off
this.lastSetSpeed = 0;
this.lastSetPercentage = 0;
}
// Air quality updates have been removed
}
/**
* Get target state (MANUAL = 0, AUTO = 1)
*/
private async getTargetState(): Promise<CharacteristicValue> {
// For Core200S, always return MANUAL
if (this.device.deviceType.includes('Core200S')) {
return 0; // MANUAL
}
const extendedDevice = this.device as ExtendedVeSyncAirPurifier;
const targetState = extendedDevice.mode === 'auto' ? 1 : 0;
return targetState;
}
/**
* Set target state (AUTO = 0, MANUAL = 1)
*/
private async setTargetState(value: CharacteristicValue): Promise<void> {
try {
const targetState = value as number;
const extendedDevice = this.device as ExtendedVeSyncAirPurifier;
// For Core200S, don't allow mode changes
if (this.device.deviceType.includes('Core200S')) {
this.platform.log.warn(`Mode changes not supported on ${this.device.deviceName}, only fan speed control is available`);
// Update the characteristic back to MANUAL (0)
this.service.updateCharacteristic(this.platform.Characteristic.TargetAirPurifierState, 0);
return;
}
// For all other devices, including Core300S, normal mode handling
const mode = targetState === 1 ? 'auto' : 'manual';
this.platform.log.debug(`Setting mode to ${mode} for device: ${this.device.deviceName}`);
let success = false;
// Use the appropriate method to set the mode
if (targetState === 1 && typeof extendedDevice.autoMode === 'function') {
success = await extendedDevice.autoMode();
} else if (targetState === 0 && typeof extendedDevice.manualMode === 'function') {
success = await extendedDevice.manualMode();
} else if (typeof extendedDevice.setMode === 'function') {
success = await extendedDevice.setMode(mode as 'auto' | 'manual' | 'sleep');
} else if (typeof this.device.setMode === 'function') {
success = await this.device.setMode(mode);
} else {
throw new Error('Device API does not support mode setting operations');
}
if (!success) {
throw new Error(`Failed to set mode to ${mode}`);
}
// Update device state and characteristics
await this.updateDeviceSpecificStates(this.device);
} catch (error) {
this.handleDeviceError('set target state', error);
}
}
/**
* Get rotation speed
*/
private async getRotationSpeed(): Promise<CharacteristicValue> {
// If device is off or speed is not defined, return 0
if (this.device.deviceStatus !== 'on' ||
this.device.speed === undefined ||
this.device.speed === null) {
return 0;
}
// If we have a last set percentage, use that instead of calculating from device speed
if (this.lastSetPercentage > 0 && this.lastSetSpeed === this.device.speed) {
this.platform.log.debug(`getRotationSpeed returning last set percentage: ${this.lastSetPercentage} for speed: ${this.device.speed}`);
return this.lastSetPercentage;
}
// Convert device speed to percentage using our consistent conversion method
const percentage = this.speedToPercentage(this.device.speed);
this.platform.log.debug(`getRotationSpeed calculated percentage: ${percentage} for speed: ${this.device.speed}`);
return percentage;
}
/**
* Set rotation speed
*/
private async setRotationSpeed(value: CharacteristicValue): Promise<void> {
try {
// Ensure value is a valid number
let percentage = value as number;
if (isNaN(percentage) || percentage === null || percentage === undefined) {
this.platform.log.warn(`Invalid rotation speed value: ${value} for device: ${this.device.deviceName}`);
return;
}
// Ensure percentage is between 0 and 100
percentage = Math.min(100, Math.max(0, percentage));
this.platform.log.debug(`Setting rotation speed to ${percentage}% for device: ${this.device.deviceName}`);
if (percentage === 0) {
// Turn off the device instead of setting speed to 0
this.platform.log.debug(`Turning off device ${this.device.deviceName} due to 0% rotation speed`);
const success = await this.device.turnOff();
if (!success) {
throw new Error('Failed to turn off device');
}
// Reset tracking variables when turning off
this.lastSetSpeed = 0;
this.lastSetPercentage = 0;
return;
}
const extendedDevice = this.device as ExtendedVeSyncAirPurifier;
// Check if fan speed control is supported in current mode
if (!this.isFeatureSupportedInCurrentMode('fan_speed')) {
this.platform.log.warn(`Fan speed control not supported in ${extendedDevice.mode} mode for device: ${this.device.deviceName}`);
// For Air131 devices, switch to manual mode first
if (this.isAir131Device && extendedDevice.mode !== 'manual') {
this.platform.log.debug(`Setting device to manual mode before changing fan speed: ${this.device.deviceName}`);
// Use the appropriate method to set manual mode
if (typeof extendedDevice.manualMode === 'function') {
const modeSuccess = await extendedDevice.manualMode();
this.platform.log.debug(`Set manual mode result: ${modeSuccess ? 'success' : 'failed'}`);
} else if (typeof extendedDevice.setMode === 'function') {
const modeSuccess = await extendedDevice.setMode('manual');
this.platform.log.debug(`Set manual mode result: ${modeSuccess ? 'success' : 'failed'}`);
}
}
}
// Convert percentage to device speed using our consistent conversion method
const speed = this.percentageToSpeed(percentage);
// Final validation to ensure speed is a valid number
if (isNaN(speed) || speed === null || speed === undefined) {
this.platform.log.warn(`Calculated invalid speed: ${speed} from percentage: ${percentage} for device: ${this.device.deviceName}`);
return;
}
this.platform.log.debug(`Setting fan speed to ${speed} for device: ${this.device.deviceName}`);
// Store the values we're setting before making the API call
this.lastSetSpeed = speed;
this.lastSetPercentage = percentage;
// Set the flag to skip the next update
this.skipNextUpdate = true;
// Immediately update the characteristic to show the correct percentage
// This ensures the slider shows the value we set, not what the device reports
this.service.updateCharacteristic(
this.platform.Characteristic.RotationSpeed,
percentage
);
const success = await this.device.changeFanSpeed(speed);
if (!success) {
// If the API call failed, reset our tracking variables
this.lastSetSpeed = 0;
this.lastSetPercentage = 0;
throw new Error(`Failed to set speed to ${speed}`);
}
this.platform.log.debug(`Successfully set fan speed to ${speed} (${percentage}%) for device: ${this.device.deviceName}`);
// Update device state and characteristics
// Skip this to avoid overriding our UI update
// await this.updateDeviceSpecificStates(this.device);
} catch (error) {
this.handleDeviceError('set rotation speed', error);
}
}
private async getActive(): Promise<CharacteristicValue> {
return this.device.deviceStatus === 'on' ? 1 : 0;
}
private async setActive(value: CharacteristicValue): Promise<void> {
try {
const isOn = value as number === 1;
this.platform.log.debug(`Setting device ${this.device.deviceName} to ${isOn ? 'on' : 'off'}`);
const success = isOn ? await this.device.turnOn() : await this.device.turnOff();
if (!success) {
throw new Error(`Failed to turn ${isOn ? 'on' : 'off'} device`);
}
// Reset tracking variables when turning off
if (!isOn) {
this.lastSetSpeed = 0;
this.lastSetPercentage = 0;
}
// Update device state and characteristics
await this.updateDeviceSpecificStates(this.device);
await this.persistDeviceState('deviceStatus', isOn ? 'on' : 'off');
} catch (error) {
this.handleDeviceError('set active state', error);
}
}
}