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; // Add additional methods autoMode?(): Promise; manualMode?(): Promise; sleepMode?(): Promise; turboMode?(): Promise; petMode?(): Promise; setDisplay?(on: boolean): Promise; turnOnDisplay?(): Promise; turnOffDisplay?(): Promise; setChildLock?(enabled: boolean): Promise; setOscillation?(enabled: boolean): Promise; hasFeature?(feature: string): boolean; isFeatureSupportedInCurrentMode?(feature: string): boolean; getMaxFanSpeed?(): number; getSupportedModes?(): ReadonlyArray; getSupportedAutoPreferences?(): ReadonlyArray; // Filter life property from VeSyncFan base class (inherited from VeSyncAirPurifier) // filterLife: number; // Already defined in base interface details?: { filter_life?: number | { percent: number }; child_lock?: boolean; air_quality_value?: number; air_quality?: string | number; screen_status?: 'on' | 'off'; pm25?: number; pm10?: number; pm1?: number; mode?: string; }; } /** * The subset of a HomeKit write batch this accessory has to reconcile. * Each field is present only if that characteristic was written. */ interface PendingWrite { active?: number; targetState?: number; rotationSpeed?: number; } export class AirPurifierAccessory extends BaseAccessory { /** * How long to buffer characteristic writes before applying them. HAP-NodeJS * dispatches every characteristic in one request within the same tick, so a * short window is enough to see the whole batch; it stays far below HAP's * 3s slow-write warning threshold. */ private static readonly WRITE_COALESCE_MS = 50; private readonly capabilities: DeviceCapabilities; protected readonly device: VeSyncAirPurifier; private isAirBypassDevice: boolean; private isAirBaseV2Device: boolean; private isAir131Device: boolean; 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 // Coalesced write state. HomeKit scenes and automations send Active, // TargetAirPurifierState and RotationSpeed in a single HAP request, and // HAP-NodeJS dispatches those writes concurrently. Applying them // independently makes them race and clobber each other, so they are // buffered here and resolved into one intent. See resolveWrite(). private pendingWrite: PendingWrite | null = null; private pendingWriteTimer: NodeJS.Timeout | null = null; private pendingWritePromise: Promise | null = null; // Batches are applied one at a time. Dragging the slider produces a burst of // writes spaced further apart than the coalesce window, so several batches // can be in flight at once; without this their API calls interleave and the // device can settle on whichever happens to land last rather than the last // value the user chose. private writeChain: Promise = Promise.resolve(); // Tracks the last mode we commanded, to protect against stale API responses. // VeSync is eventually consistent: for a few seconds after a mode change // getDetails() still reports the *previous* mode, which would otherwise flip // the HomeKit tile straight back (auto->manual, or manual->auto). Mirrors the // guard the humidifier accessory already uses. Expires so an out-of-band // change made in the VeSync app is not masked indefinitely. private static readonly COMMANDED_MODE_TTL_MS = 30000; private _lastCommandedMode: string | null = null; private _lastCommandedModeTime = 0; 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 // Use case-insensitive check for Core series to handle 'Core300S', 'CORE300S', etc. this.isAirBypassDevice = deviceType.toUpperCase().includes('CORE') || deviceType.startsWith('LAP-C') || deviceType.toUpperCase().includes('VITAL'); // AirBaseV2 Devices: LAP-V series, LAP-EL series, EverestAir series this.isAirBaseV2Device = deviceType.startsWith('LAP-V') || deviceType.startsWith('LAP-EL') || deviceType.toUpperCase().includes('EVERESTAIR'); // Air131 Devices: LV-PUR131S, LV-RH131S this.isAir131Device = deviceType.startsWith('LV-'); // Log device class detection with more detail this.platform.log.debug(`Device Classification for ${this.device.deviceName}:`); this.platform.log.debug(` - Device Type: "${deviceType}"`); this.platform.log.debug(` - Is AirBypass: ${this.isAirBypassDevice}`); this.platform.log.debug(` - Is AirBaseV2: ${this.isAirBaseV2Device}`); this.platform.log.debug(` - Is Air131: ${this.isAir131Device}`); 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.warn(`Unknown device class: ${this.device.deviceName} (${deviceType}) - device may not function properly`); } // Remove any cached air quality service - these are now separate accessories const cachedAirQualityService = this.accessory.getService(this.platform.Service.AirQualitySensor); if (cachedAirQualityService) { this.platform.log.debug(`${this.device.deviceName} (${deviceType}): Removing cached embedded AQ service - AQ sensors are now separate accessories`); this.accessory.removeService(cachedAirQualityService); } } /** * Feature detection system */ private hasFeature(feature: string): boolean { const extendedDevice = this.device as unknown as ExtendedVeSyncAirPurifier; // **ENHANCED DEBUGGING**: Log detailed device type information this.platform.log.debug(`${this.device.deviceName}: FEATURE CHECK - hasFeature('${feature}') for device type: "${this.device.deviceType}"`); // Use device's native hasFeature method if available if (typeof extendedDevice.hasFeature === 'function') { const result = extendedDevice.hasFeature(feature); this.platform.log.debug(`${this.device.deviceName} (${this.device.deviceType}): Native hasFeature('${feature}') returned: ${result} (using tsvesync library configuration)`); // For air_quality, trust the library's configuration completely // The library was updated in v1.0.107 to correctly exclude air_quality for devices without sensors if (feature === 'air_quality') { this.platform.log.debug(`${this.device.deviceName} (${this.device.deviceType}): Air quality feature decision based on tsvesync library config: ${result}`); // Add extra debugging for Core200S devices if (this.device.deviceType.includes('Core200S') || this.device.deviceType.includes('LAP-C20')) { this.platform.log.debug(`${this.device.deviceName} (${this.device.deviceType}): Core200S variant detected - should NOT have air quality! hasFeature returned: ${result}`); } return result; } // **CRITICAL FIX**: Enhanced filter_life detection with explicit Core300S support if (feature === 'filter_life') { const deviceType = this.device.deviceType; this.platform.log.debug(`${this.device.deviceName} (${deviceType}): Native hasFeature('filter_life') returned: ${result}`); // **EXPLICIT Core300S CHECK**: Make sure Core300S is always recognized if (deviceType === 'Core300S' || deviceType.includes('Core300S')) { this.platform.log.debug(`${this.device.deviceName}: Core300S detected - SHOULD support filter_life! Native result: ${result}`); if (!result) { this.platform.log.warn(`${this.device.deviceName}: Core300S should support filter_life but native hasFeature returned false. OVERRIDING to true.`); return true; } } if (!result) { // Check if this looks like a device type that should support filter_life if (deviceType.includes('Core') || deviceType.includes('300S') || // Add explicit check for 300S deviceType.includes('200S') || // Add explicit check for 200S deviceType.includes('400S') || // Add explicit check for 400S deviceType.includes('600S') || // Add explicit check for 600S deviceType.includes('LAP-') || deviceType.includes('LV-') || deviceType.includes('Vital')) { this.platform.log.warn(`${this.device.deviceName}: Device type ${deviceType} should support filter_life but native hasFeature returned false. Overriding to true.`); return true; } } } // **EXPLICIT Core300S auto_mode CHECK**: Make sure Core300S auto mode is recognized if (feature === 'auto_mode') { const deviceType = this.device.deviceType; this.platform.log.debug(`${this.device.deviceName} (${deviceType}): Native hasFeature('auto_mode') returned: ${result}`); if (deviceType === 'Core300S' || deviceType.includes('Core300S')) { this.platform.log.debug(`${this.device.deviceName}: Core300S detected - SHOULD support auto_mode! Native result: ${result}`); if (!result) { this.platform.log.warn(`${this.device.deviceName}: Core300S should support auto_mode but native hasFeature returned false. OVERRIDING to true.`); return true; } } } return result; } // Fallback feature detection based on device type switch (feature) { case 'air_quality': // Respect the tsvesync library's feature configuration for air quality // Only devices with actual hardware sensors should have air quality features // The library already correctly configures which devices support air quality this.platform.log.debug(`${this.device.deviceName}: Fallback hasFeature('${feature}') returned: false (device does not have air quality sensor per tsvesync config)`); return false; case 'child_lock': { // Every current VeSync purifier model has a panel lock; when the // library is too old to advertise features, key off whether the // device class implements the setter. const canSetChildLock = typeof extendedDevice.setChildLock === 'function'; this.platform.log.debug(`${this.device.deviceName}: Fallback hasFeature('${feature}') returned: ${canSetChildLock} (based on setChildLock availability)`); return canSetChildLock; } case 'display': // Explicitly disable display control features this.platform.log.debug(`${this.device.deviceName}: Fallback hasFeature('${feature}') returned: false (explicitly disabled)`); return false; case 'filter_life': // For air purifiers, enable filter life features based on device type patterns const deviceType = this.device.deviceType; // Check device type patterns that should support filter_life const supportsFilterByType = deviceType.includes('Core') || deviceType.includes('300S') || // Explicit check for 300S deviceType.includes('200S') || // Explicit check for 200S deviceType.includes('400S') || // Explicit check for 400S deviceType.includes('600S') || // Explicit check for 600S deviceType.includes('LAP-') || deviceType.includes('LV-') || deviceType.includes('Vital'); if (supportsFilterByType) { this.platform.log.debug(`${this.device.deviceName}: Device type ${deviceType} supports filter_life by pattern matching`); return true; } // Fallback: Check if filter_life data exists in any format const filterDevice = this.device as unknown as ExtendedVeSyncAirPurifier; const filterLife = filterDevice.details?.filter_life; const hasFilterLife = !!(filterLife !== undefined && (typeof filterLife === 'number' || (typeof filterLife === 'object' && filterLife !== null && 'percent' in filterLife))); // Also check for filterLife property from VeSyncFan base class const hasFilterLifeProperty = typeof extendedDevice.filterLife === 'number'; const result = hasFilterLife || hasFilterLifeProperty; this.platform.log.debug(`${this.device.deviceName}: Fallback hasFeature('${feature}') - details.filter_life: ${JSON.stringify(filterLife)}, device.filterLife: ${extendedDevice.filterLife}, result: ${result}`); return result; case 'fan_speed': this.platform.log.debug(`${this.device.deviceName}: Fallback hasFeature('${feature}') returned: true`); return true; default: this.platform.log.debug(`${this.device.deviceName}: Fallback hasFeature('${feature}') returned: false (unknown feature)`); 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; } private isCore200S(): boolean { return (this.device.deviceType || '').toUpperCase().includes('CORE200S'); } private isCore300S(): boolean { return (this.device.deviceType || '').toUpperCase().includes('CORE300S'); } /** * The real number of manual fan speeds, which is not always what the library * advertises. tsvesync declares `levels: [1,2,3,4]` for the Core 200S and * Core 300S, but both hardware families only have three manual speeds - * verified against a Core300S, where changeFanSpeed(4) reports success and * the device settles at 3. Trusting the declared 4 produces a dead top notch * where 80% and 100% both mean speed 3. */ private getEffectiveMaxFanSpeed(): number { if (this.isCore200S() || this.isCore300S()) { return 3; } return this.getMaxFanSpeed(); } /** * Calculate the appropriate step size for discrete speed levels */ private calculateRotationSpeedStep(): number { // Expose Sleep as the first notch when supported. The step is simply the // width of one notch, derived from the notch count so the slider grid and // the percentage->notch mapping can never disagree. if (this.hasFeature('sleep_mode')) { return 100 / this.getTotalNotches(); } const maxSpeed = this.getMaxFanSpeed(); if (maxSpeed === 3) return 33.33; if (maxSpeed === 4) return 25; return 1; } /** * Total slider notches: sleep, each manual speed, and turbo when supported. */ private getTotalNotches(): number { const max = this.getEffectiveMaxFanSpeed(); return max + (this.hasFeature('turbo_mode') ? 2 : 1); } /** * Map a percentage to a slider notch by which equal-width range it falls in, * rather than by rounding against our own step. * * This has to stay correct even when the controller disagrees with us about * the step. HomeKit caches characteristic metadata on the home hub, so after * an upgrade that changes minStep a controller can keep sending values on the * *old* grid for an unknown period. Rounding against our step maps those to * the wrong speed (a 60% on an old 20% grid became Low instead of Medium); * range mapping gets it right on either grid. */ private percentageToNotch(percentage: number): number { if (typeof percentage !== 'number' || isNaN(percentage) || percentage <= 0) { return 0; } const totalNotches = this.getTotalNotches(); const sliceWidth = 100 / totalNotches; return Math.max(1, Math.min(totalNotches, Math.ceil(percentage / sliceWidth))); } /** * Convert device speed to percentage */ private speedToPercentage(speed: number): number { // Sleep as first notch: map manual speeds to notches based on model if (this.hasFeature('sleep_mode')) { const extended = this.device as ExtendedVeSyncAirPurifier; if (this.hasFeature('turbo_mode') && (extended.mode || '').toLowerCase() === 'turbo') { return 100; } if (typeof speed !== 'number' || speed <= 0) return 0; const step = this.calculateRotationSpeedStep(); const max = this.getEffectiveMaxFanSpeed(); // Manual speeds occupy notches 2..(max+1) const notch = Math.max(2, Math.min(max + 1, speed + 1)); return Math.min(100, notch * step); } const maxSpeed = this.getMaxFanSpeed(); if (maxSpeed <= 0 || typeof speed !== 'number' || speed <= 0) return 0; if (maxSpeed === 3) return speed === 1 ? 33.33 : speed === 2 ? 66.67 : 100; if (maxSpeed === 4) return speed === 1 ? 25 : speed === 2 ? 50 : speed === 3 ? 75 : 100; return Math.min(100, Math.max(0, Math.round((speed / maxSpeed) * 100))); } /** * Updates the rotation speed characteristic for Air131 devices immediately after turning on. * This ensures the UI shows the restored speed instead of 0, providing better user experience. * * Air131 devices remember their last speed setting when turned back on, but the characteristic * update can be delayed. This method proactively updates the HomeKit characteristic to reflect * the actual device speed. * * @private * @returns {Promise} Promise that resolves when the update is complete * @throws Will log errors through handleDeviceError but won't propagate them to avoid * disrupting the main turn-on operation */ private async updateAir131RotationSpeedAfterTurnOn(): Promise { try { // Validate device and service state if (!this.device || !this.service) { this.platform.log.warn(`${this.device?.deviceName || 'Unknown'}: Cannot update rotation speed - device or service not available`); return; } // Check if device has a valid speed value if (typeof this.device.speed !== 'number' || this.device.speed <= 0) { this.platform.log.debug(`${this.device.deviceName}: No valid speed to restore (speed: ${this.device.speed})`); return; } // Convert device speed to percentage with validation const percentage = this.speedToPercentage(this.device.speed); if (typeof percentage !== 'number' || isNaN(percentage) || percentage < 0 || percentage > 100) { this.platform.log.warn(`${this.device.deviceName}: Invalid percentage calculated (${percentage}) from speed ${this.device.speed}`); return; } // Update the HomeKit characteristic this.service.updateCharacteristic( this.platform.Characteristic.RotationSpeed, percentage ); this.platform.log.debug( `${this.device.deviceName}: Updated rotation speed to ${percentage}% (speed: ${this.device.speed}) after turning on` ); } catch (error) { this.handleDeviceError('update Air131 rotation speed after turn on', error); } } /** * Convert percentage to device speed */ private percentageToSpeed(percentage: number): number { // Sleep as first notch: round to nearest step and map notches → speeds if (this.hasFeature('sleep_mode')) { if (typeof percentage !== 'number') return 1; percentage = Math.min(100, Math.max(0, percentage)); const max = this.getEffectiveMaxFanSpeed(); const supportsTurbo = this.hasFeature('turbo_mode'); const totalNotches = this.getTotalNotches(); // sleep + manual + optional turbo const notch = this.percentageToNotch(percentage); if (supportsTurbo && notch === totalNotches) { return max; } const manualMaxNotch = totalNotches - (supportsTurbo ? 1 : 0); const manualNotch = Math.max(2, Math.min(manualMaxNotch, notch)); return Math.min(max, manualNotch - 1); } const maxSpeed = this.getMaxFanSpeed(); if (maxSpeed <= 0 || typeof percentage !== 'number') return 1; percentage = Math.min(100, Math.max(0, percentage)); if (maxSpeed === 3) return percentage <= 33.34 ? 1 : percentage <= 66.67 ? 2 : 3; if (maxSpeed === 4) return percentage <= 25 ? 1 : percentage <= 50 ? 2 : percentage <= 75 ? 3 : 4; 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); // Mark the AirPurifier service as primary // setPrimaryService may not be available in test environment if (typeof this.service.setPrimaryService === 'function') { this.service.setPrimaryService(true); this.platform.log.debug(`${this.device.deviceName}: Marked AirPurifier service as primary`); } // **CRITICAL FIX**: Add optional characteristics FIRST before setting up handlers // This ensures they persist through service configuration this.platform.log.debug(`${this.device.deviceName} (${this.device.deviceType}): Pre-configuring optional characteristics`); // Always set up filter characteristics (like reference plugin does) // The reference implementation ALWAYS registers these for ALL air purifiers // This ensures they appear in HomeKit even if the device doesn't report having them initially this.platform.log.debug(`${this.device.deviceName}: Setting up filter characteristics for all air purifiers`); // Use getCharacteristic which auto-adds if missing (like reference plugin) this.service.getCharacteristic(this.platform.Characteristic.FilterChangeIndication) .onGet(this.getFilterChangeIndication.bind(this)); this.service.getCharacteristic(this.platform.Characteristic.FilterLifeLevel) .onGet(this.getFilterLifeLevel.bind(this)) .setProps({ minValue: 0, maxValue: 100, minStep: 1 }); this.platform.log.debug(`${this.device.deviceName}: Filter characteristics registered (always present for air purifiers)`) // 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); // Check if device supports auto mode const hasAutoMode = this.hasFeature('auto_mode'); this.platform.log.debug(`${this.device.deviceName} (${this.device.deviceType}): Auto mode support check - hasFeature('auto_mode') = ${hasAutoMode}`); // For Core200S, only allow manual mode if (this.device.deviceType.includes('Core200S')) { targetStateChar.setProps({ validValues: [0] // MANUAL only }); this.platform.log.debug(`${this.device.deviceName} (Core200S): Configured for MANUAL mode only - no auto mode support`); } else if (this.device.deviceType.includes('Core300S')) { // Core300S explicitly supports both modes targetStateChar.setProps({ validValues: [0, 1] // MANUAL and AUTO }); this.platform.log.debug(`${this.device.deviceName} (Core300S): ✅ Configured for BOTH AUTO and MANUAL modes - auto mode ENABLED`); this.platform.log.debug(`${this.device.deviceName} (Core300S): Target state characteristic should now show mode switcher in HomeKit`); } else if (hasAutoMode) { // Other devices with auto_mode feature targetStateChar.setProps({ validValues: [0, 1] // MANUAL and AUTO }); this.platform.log.debug(`${this.device.deviceName} (${this.device.deviceType}): Configured for AUTO and MANUAL modes based on feature detection`); } else { // Devices without auto mode targetStateChar.setProps({ validValues: [0] // MANUAL only }); this.platform.log.debug(`${this.device.deviceName} (${this.device.deviceType}): Configured for MANUAL mode only - no auto mode feature`); } // Set up the characteristic handlers for all 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 rotation speed characteristic using the simplified approach (like reference plugin) const minStep = this.calculateRotationSpeedStep(); this.service.getCharacteristic(this.platform.Characteristic.RotationSpeed) .onGet(this.getRotationSpeed.bind(this)) .onSet(this.setRotationSpeed.bind(this)) .setProps({ minValue: 0, maxValue: 100, minStep: minStep }); this.platform.log.debug(`${this.device.deviceName}: Configured RotationSpeed with minStep: ${minStep}`); // Add Name characteristic this.setupCharacteristic( this.platform.Characteristic.Name, async () => this.sanitizedDeviceName ); // Air quality sensors are now separate accessories // Remove any legacy embedded AQ service const existingAirQualityService = this.accessory.getService(this.platform.Service.AirQualitySensor); if (existingAirQualityService) { this.platform.log.debug(`${this.device.deviceName}: Removing legacy embedded air quality service`); this.accessory.removeService(existingAirQualityService); } // Clean up any existing FilterMaintenance service (from old implementation) const existingFilterService = this.accessory.getService(this.platform.Service.FilterMaintenance); if (existingFilterService) { this.platform.log.debug(`${this.device.deviceName}: Removing old FilterMaintenance service (migrating to AirPurifier service)`); this.accessory.removeService(existingFilterService); } // Add a Pet Mode switch for devices that support it (e.g. Vital 200S / LAP-V201S) this.setupPetModeSwitch(); // Expose the panel lock ("Display Lock" on the device) in the // accessory's settings pane for devices that support it this.setupChildLock(); // Check and log important features for debugging const autoModeSupported = this.hasFeature('auto_mode'); this.platform.log.debug(`${this.device.deviceName} (${this.device.deviceType}): Features detected:`); this.platform.log.debug(` - auto_mode: ${autoModeSupported} (controls mode switch)`); this.platform.log.debug(` - pet_mode: ${this.hasFeature('pet_mode')} (controls Pet Mode switch)`); this.platform.log.debug(` - filter_life: ${this.hasFeature('filter_life')} (controls filter display)`); this.platform.log.debug(` - child_lock: ${this.hasFeature('child_lock')} (controls LockPhysicalControls)`); } /** * Pet mode is exposed as a dedicated Switch service because HomeKit's * AirPurifier service has no native representation for it. Only added for * devices whose tsvesync profile advertises the `pet_mode` feature * (e.g. the Vital 200S / LAP-V201S family); any stale cached switch is * removed for devices that don't support it. */ private static readonly PET_MODE_SERVICE_NAME = 'Pet Mode'; private setupPetModeSwitch(): void { const name = AirPurifierAccessory.PET_MODE_SERVICE_NAME; const existing = this.accessory.getService(name); if (!this.hasFeature('pet_mode')) { if (existing) { this.platform.log.debug(`${this.device.deviceName}: Removing Pet Mode switch - device does not support pet mode`); this.accessory.removeService(existing); } return; } const petService = existing || this.accessory.addService(this.platform.Service.Switch, name, 'pet-mode'); if (!petService) { this.platform.log.warn(`${this.device.deviceName}: Unable to create Pet Mode switch service`); return; } petService.getCharacteristic(this.platform.Characteristic.On) .onGet(this.getPetMode.bind(this)) .onSet(this.setPetMode.bind(this)); this.platform.log.debug(`${this.device.deviceName}: Pet Mode switch configured`); } /** * Get pet mode state (on when the device's current mode is 'pet') */ private async getPetMode(): Promise { const extendedDevice = this.device as ExtendedVeSyncAirPurifier; return this.resolveEffectiveMode(extendedDevice.mode) === 'pet'; } /** * Set pet mode. Enabling switches the device into pet mode; disabling * returns it to auto (pet is an automatic mode), falling back to manual * for the rare device that supports pet but not auto. */ private async setPetMode(value: CharacteristicValue): Promise { try { const extendedDevice = this.device as ExtendedVeSyncAirPurifier; let success = false; if (value) { this.platform.log.info(`Enabling pet mode for device: ${this.device.deviceName}`); if (typeof extendedDevice.petMode === 'function') { success = await extendedDevice.petMode(); } else if (typeof extendedDevice.setMode === 'function') { success = await extendedDevice.setMode('pet'); } else { throw new Error('Device API does not support pet mode'); } } else { const fallbackMode = this.hasFeature('auto_mode') ? 'auto' : 'manual'; this.platform.log.info(`Disabling pet mode for device: ${this.device.deviceName} (returning to ${fallbackMode})`); if (fallbackMode === 'auto' && typeof extendedDevice.autoMode === 'function') { success = await extendedDevice.autoMode(); } else if (fallbackMode === 'manual' && typeof extendedDevice.manualMode === 'function') { success = await extendedDevice.manualMode(); } else if (typeof extendedDevice.setMode === 'function') { success = await extendedDevice.setMode(fallbackMode); } else { throw new Error('Device API does not support mode setting operations'); } } if (!success) { throw new Error(`Failed to ${value ? 'enable' : 'disable'} pet mode`); } // Hold the commanded mode against stale API reads this.rememberCommandedMode( value ? 'pet' : (this.hasFeature('auto_mode') ? 'auto' : 'manual') ); // Refresh characteristics so the main service and switch stay consistent await this.updateDeviceSpecificStates(this.device); } catch (error) { await this.handleDeviceError('set pet mode', error); throw error; } } /** * The panel lock (marketed as "Display Lock" on Core-series purifiers, * "Child Lock" elsewhere) maps to HomeKit's LockPhysicalControls, which * the Home app shows in the accessory's settings pane rather than as a * tile. Only wired when the device profile advertises the feature and the * tsvesync class implements the setter. */ private supportsChildLock(): boolean { const extendedDevice = this.device as ExtendedVeSyncAirPurifier; return typeof extendedDevice.setChildLock === 'function' && this.hasFeature('child_lock'); } private setupChildLock(): void { const lockCharacteristic = this.platform.Characteristic.LockPhysicalControls; if (!this.supportsChildLock()) { // Drop a stale characteristic left behind by an older cached accessory if (typeof this.service.testCharacteristic === 'function' && this.service.testCharacteristic(lockCharacteristic)) { this.platform.log.debug(`${this.device.deviceName}: Removing LockPhysicalControls - device does not support child lock`); this.service.removeCharacteristic(this.service.getCharacteristic(lockCharacteristic)); } return; } this.setupCharacteristic( lockCharacteristic, this.getLockPhysicalControls.bind(this), this.setLockPhysicalControls.bind(this) ); this.platform.log.debug(`${this.device.deviceName}: LockPhysicalControls configured`); } private async getLockPhysicalControls(): Promise { const extendedDevice = this.device as ExtendedVeSyncAirPurifier; const locked = extendedDevice.childLock ?? extendedDevice.details?.child_lock ?? false; return locked ? 1 : 0; } private async setLockPhysicalControls(value: CharacteristicValue): Promise { try { const extendedDevice = this.device as ExtendedVeSyncAirPurifier; if (typeof extendedDevice.setChildLock !== 'function') { throw new Error('Device does not support child lock'); } const enabled = (value as number) === 1; const success = await extendedDevice.setChildLock(enabled); if (!success) { throw new Error(`Failed to ${enabled ? 'enable' : 'disable'} child lock`); } await this.persistDeviceState('child_lock', enabled); } catch (error) { await this.handleDeviceError('set child lock', error); throw error; } } /** * Extract filter life from device data in a centralized way */ private extractFilterLife(): number { const extendedDevice = this.device as unknown as ExtendedVeSyncAirPurifier; // Priority 1: Use device's normalized filterLife property if (typeof extendedDevice.filterLife === 'number' && !isNaN(extendedDevice.filterLife)) { return extendedDevice.filterLife; } // Priority 2: Check details.filter_life const filterLifeData = extendedDevice.details?.filter_life; // Handle number format if (typeof filterLifeData === 'number' && !isNaN(filterLifeData)) { return filterLifeData; } // Handle object format { percent: number } if (typeof filterLifeData === 'object' && filterLifeData !== null && 'percent' in filterLifeData) { const percent = (filterLifeData as { percent: number }).percent; return (typeof percent === 'number' && !isNaN(percent)) ? percent : 100; } // Default to 100% (filter OK) return 100; } /** * Get filter change indication */ private async getFilterChangeIndication(): Promise { const filterLife = this.extractFilterLife(); // Indicate filter change needed when life is below 10% return filterLife < 10 ? 1 : 0; // 0=FILTER_OK, 1=CHANGE_FILTER } /** * Get filter life level */ private async getFilterLifeLevel(): Promise { const filterLife = this.extractFilterLife(); // Ensure value is within HomeKit limits (0-100) and round to nearest integer return Math.round(Math.min(100, Math.max(0, filterLife))); } /** * Get current state (INACTIVE = 0, IDLE = 1, PURIFYING_AIR = 2) */ private async getCurrentState(): Promise { return this.device.deviceStatus === 'on' ? 2 : 0; } /** * Get device capabilities */ protected getDeviceCapabilities(): DeviceCapabilities { return { hasSpeed: true, hasAirQuality: false, // Air quality is now handled by separate accessories hasChildLock: true, // Panel lock, exposed via LockPhysicalControls 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 { // Update power state (treat sleep as on for some VeSync models). // Prefer the mode we last commanded - a poll that lands while VeSync is // still reporting the previous mode must not flip the tile back. const extended = this.device as ExtendedVeSyncAirPurifier; const mode = this.resolveEffectiveMode(extended.mode || details.mode || ''); const isSleep = mode === 'sleep'; const isTurbo = this.hasFeature('turbo_mode') && mode === 'turbo'; const isOn = isSleep || 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 ); // 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 (mode) { // For other devices, including Core300S, use the effective mode resolved // above. Pet mode is an automatic mode, so surface it as AUTO. targetState = this.modeToTargetState(mode); } this.service.updateCharacteristic( this.platform.Characteristic.TargetAirPurifierState, targetState ); // Keep the Pet Mode switch (if present) in sync with the device mode const petService = this.accessory.getService(AirPurifierAccessory.PET_MODE_SERVICE_NAME); if (petService) { petService.updateCharacteristic(this.platform.Characteristic.On, mode === 'pet'); } // Update rotation speed if (isOn && isTurbo) { this.service.updateCharacteristic(this.platform.Characteristic.RotationSpeed, 100); this.lastSetSpeed = this.getMaxFanSpeed(); this.lastSetPercentage = 100; } else if (isOn && this.hasFeature('sleep_mode') && isSleep) { const step = this.calculateRotationSpeedStep(); this.service.updateCharacteristic(this.platform.Characteristic.RotationSpeed, step); this.lastSetSpeed = 0; this.lastSetPercentage = step; } else 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 is now handled by separate accessories // No need to update AQ characteristics here // Update filter characteristics on main service if supported if (this.hasFeature('filter_life')) { const filterLife = this.extractFilterLife(); // Update filter change indication on main service this.service?.updateCharacteristic( this.platform.Characteristic.FilterChangeIndication, filterLife < 10 ? 1 : 0 ); // Update filter life level on main service this.service?.updateCharacteristic( this.platform.Characteristic.FilterLifeLevel, Math.min(100, Math.max(0, filterLife)) ); } // Keep the panel lock in sync with the device if (this.supportsChildLock()) { const locked = extended.childLock ?? extended.details?.child_lock ?? false; this.service.updateCharacteristic( this.platform.Characteristic.LockPhysicalControls, locked ? 1 : 0 ); } } /** * Buffer a characteristic write and return a promise that settles once the * whole batch has been applied. Every characteristic in the batch shares the * same promise, so HomeKit gets a success/failure per write as usual. */ private queueWrite(key: keyof PendingWrite, value: number): Promise { if (!this.pendingWrite) { this.pendingWrite = {}; } this.pendingWrite[key] = value; if (!this.pendingWritePromise) { this.pendingWritePromise = new Promise((resolve, reject) => { this.pendingWriteTimer = setTimeout(() => { const batch = this.pendingWrite ?? {}; this.pendingWrite = null; this.pendingWritePromise = null; this.pendingWriteTimer = null; // Queue behind any batch still being applied so commands reach the // device in the order the user made them. this.writeChain = this.writeChain .catch(() => undefined) .then(() => this.resolveWrite(batch)); this.writeChain.then(resolve, reject); }, AirPurifierAccessory.WRITE_COALESCE_MS); // NB: deliberately not unref()'d - an unref'd timer lets the process // exit before the buffered write is ever applied. }); } return this.pendingWritePromise; } /** * Turn one buffered batch of HomeKit writes into a single device intent. * * The ordering rules that matter (issue #42): * - An explicit AUTO write wins over the mode implied by the speed slider. * This accessory encodes sleep/manual/turbo into RotationSpeed, so every * speed write is implicitly a mode write; without this rule an automation * that sets "on + auto + 60%" ends up in manual. * - RotationSpeed 0 only means "turn off" when the same batch didn't also * ask for the device to be on or set a mode. Otherwise "on + auto + 0%" * turns the device on and straight back off. */ private async resolveWrite(batch: PendingWrite): Promise { const { active, targetState } = batch; let { rotationSpeed } = batch; this.platform.log.debug( `${this.device.deviceName}: HomeKit write batch ${JSON.stringify(batch)} ` + `(device reports mode=${(this.device as ExtendedVeSyncAirPurifier).mode}, ` + `status=${this.device.deviceStatus}, speed=${this.device.speed})` ); // An explicit power-off wins outright - nothing else in the batch matters. if (active === 0) { await this.applyActive(0); return; } if (rotationSpeed === 0) { if (active !== 1 && targetState === undefined) { // Slider dragged to zero on its own - the long-standing "0% means off". await this.applyActive(0); return; } // Something else in the batch asked for the device to be on or set a // mode, so the 0% slider is stale state rather than an off request. this.platform.log.debug( `${this.device.deviceName}: Ignoring 0% speed - batch also requested on/mode` ); rotationSpeed = undefined; } // Power on first so the mode and speed commands land on a running device. if (active === 1) { await this.applyActive(1); } // Explicit AUTO beats the slider. In auto the device picks its own speed, // so the requested percentage is advisory and deliberately not applied. if (targetState === 1) { await this.applyTargetState(1); return; } // Otherwise the slider carries the mode (sleep / manual / turbo). if (rotationSpeed !== undefined) { await this.applyRotationSpeed(rotationSpeed); return; } if (targetState === 0) { await this.applyTargetState(0); } } /** * Write a commanded speed/mode back into the device's cached details. * * The bypass purifiers (Core series) don't update their own cache after a * successful changeFanSpeed - unlike the bypassV2 models, which do - so * device.speed stays stale until the next poll, and the poll interval has a * two minute floor. That stale value makes getRotationSpeed() disagree with * what we just set and snaps the slider back to the wrong notch. */ private cacheCommandedSpeed(speed: number, mode?: string): void { const device = this.device as ExtendedVeSyncAirPurifier & { details?: Record; }; // NB: `speed` and `mode` are getter-only on VeSyncFan and read straight // through to `details`. Assigning to them is a no-op in sloppy mode and a // TypeError under the strict-mode build, so write to `details` instead. if (device.details) { device.details.speed = speed; if (mode) { device.details.mode = mode; } } if (mode) { this.rememberCommandedMode(mode); } } /** * Record a mode we just commanded so a stale API read can't undo it. */ private rememberCommandedMode(mode: string): void { this._lastCommandedMode = (mode || '').toLowerCase(); this._lastCommandedModeTime = Date.now(); } /** * Resolve the mode to report to HomeKit, preferring what we last commanded * over what the device currently reports. The override is dropped as soon as * the device catches up, or once it expires - so a mode changed in the VeSync * app still comes through. */ private resolveEffectiveMode(reportedMode: string): string { const reported = (reportedMode || '').toLowerCase(); if (this._lastCommandedMode === null) { return reported; } const elapsed = Date.now() - this._lastCommandedModeTime; if (reported === this._lastCommandedMode || elapsed > AirPurifierAccessory.COMMANDED_MODE_TTL_MS) { // Device caught up, or we've waited long enough - stop overriding. this._lastCommandedMode = null; return reported; } this.platform.log.debug( `${this.device.deviceName}: Using commanded mode '${this._lastCommandedMode}' ` + `instead of device-reported '${reported}'` ); return this._lastCommandedMode; } /** * Map a VeSync mode to the HomeKit TargetAirPurifierState value. * Pet is an automatic mode, so it surfaces as AUTO like auto itself. */ private modeToTargetState(mode: string): number { const normalised = (mode || '').toLowerCase(); return (normalised === 'auto' || normalised === 'pet') ? 1 : 0; } /** * Push the mode back to HomeKit immediately after we change it. * * The RotationSpeed slider implicitly carries a mode (sleep/manual/turbo), * so dragging it can move the device out of auto. Without this the tile * keeps showing the old mode until the next poll - and the poll interval has * a two minute floor, so "Auto" would linger long after the device had * actually switched to Manual. */ private syncTargetStateToHomeKit(mode: string): void { this.rememberCommandedMode(mode); const targetState = this.modeToTargetState(mode); this.service.updateCharacteristic( this.platform.Characteristic.TargetAirPurifierState, targetState ); this.platform.log.debug( `${this.device.deviceName}: Synced TargetAirPurifierState to ${targetState} for mode '${mode}'` ); } /** * Get target state (MANUAL = 0, AUTO = 1) */ private async getTargetState(): Promise { // For Core200S, always return MANUAL if (this.device.deviceType.includes('Core200S')) { return 0; // MANUAL } const extendedDevice = this.device as ExtendedVeSyncAirPurifier; // Pet mode is an automatic mode, so report it as AUTO on the main service. // Prefer the mode we last commanded so a stale API read can't flip the tile. const effectiveMode = this.resolveEffectiveMode(extendedDevice.mode); return this.modeToTargetState(effectiveMode); } /** * Set target state (MANUAL = 0, AUTO = 1). Buffered so it can be reconciled * with any Active/RotationSpeed writes sent in the same HomeKit request. */ private async setTargetState(value: CharacteristicValue): Promise { return this.queueWrite('targetState', value as number); } private async applyTargetState(value: CharacteristicValue): Promise { 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.info(`Changing 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}`); } // Remember what we commanded so the stale-read guard holds the tile on // the new mode until VeSync catches up. this.rememberCommandedMode(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 { const extended = this.device as ExtendedVeSyncAirPurifier; const effectiveMode = this.resolveEffectiveMode(extended.mode); if (this.hasFeature('turbo_mode') && effectiveMode === 'turbo') { return 100; } // Sleep occupies the first notch. This has to be checked before the // on/off gate below: while the device is ON the gate falls through to the // speed conversion, which would report the last manual speed instead of // the sleep notch. if (this.hasFeature('sleep_mode') && effectiveMode === 'sleep') { if (this.device.deviceStatus !== 'on') { return 0; } // Prefer the value the controller sent, so a controller on a stale grid // isn't dragged to our canonical notch position. return this.lastSetPercentage > 0 ? this.lastSetPercentage : this.calculateRotationSpeedStep(); } // 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. Buffered so it can be reconciled with any * Active/TargetAirPurifierState writes sent in the same HomeKit request - * the slider implicitly carries a mode, so it must not clobber an explicit * AUTO written alongside it. */ private async setRotationSpeed(value: CharacteristicValue): Promise { let percentage = value as number; if (typeof percentage !== 'number' || isNaN(percentage)) { this.platform.log.warn(`Invalid rotation speed value: ${value} for device: ${this.device.deviceName}`); return; } percentage = Math.min(100, Math.max(0, percentage)); return this.queueWrite('rotationSpeed', percentage); } private async applyRotationSpeed(value: CharacteristicValue): Promise { try { // Value is already validated and clamped by setRotationSpeed() const percentage = value as number; this.platform.log.debug(`Setting rotation speed to ${percentage}% for device: ${this.device.deviceName}`); const extendedDevice = this.device as ExtendedVeSyncAirPurifier; // Check if device is off and turn it on first if (this.device.deviceStatus !== 'on') { this.platform.log.debug(`Device ${this.device.deviceName} is off, turning on before setting speed`); const turnOnSuccess = await this.device.turnOn(); if (!turnOnSuccess) { // For LV-PUR131S, this might return false if already on, so check actual state if (this.isAir131Device && this.device.deviceStatus === 'on') { this.platform.log.debug(`${this.device.deviceName}: Device is now on`); } else { throw new Error('Failed to turn on device'); } } // Wait a bit for the device to turn on await new Promise(resolve => setTimeout(resolve, 1000)); // Update device state to get fresh mode information await this.updateDeviceSpecificStates(this.device); } // When using sleep-as-first-notch, map slider notches robustly by rounding if (this.hasFeature('sleep_mode')) { const max = this.getEffectiveMaxFanSpeed(); const supportsTurbo = this.hasFeature('turbo_mode'); const totalNotches = this.getTotalNotches(); // sleep + manual + optional turbo const notch = this.percentageToNotch(percentage); // Echo back the value the controller actually sent rather than our own // canonical notch position. If its cached step differs from ours the // two are not equal, and echoing ours makes the slider visibly jump. const echoPct = percentage; if (notch <= 1) { // Sleep this.platform.log.info(`Changing mode to sleep for device: ${this.device.deviceName}`); let ok = false; if (typeof extendedDevice.sleepMode === 'function') { ok = await extendedDevice.sleepMode(); } else if (typeof extendedDevice.setMode === 'function') { ok = await extendedDevice.setMode('sleep'); } if (!ok) { throw new Error('Failed to set sleep mode'); } // Immediate UI feedback this.service.updateCharacteristic(this.platform.Characteristic.RotationSpeed, echoPct); this.service.updateCharacteristic(this.platform.Characteristic.Active, 1); this.service.updateCharacteristic(this.platform.Characteristic.CurrentAirPurifierState, 2); this.syncTargetStateToHomeKit('sleep'); this.lastSetSpeed = 0; this.lastSetPercentage = echoPct; this.skipNextUpdate = true; return; } if (supportsTurbo && notch === totalNotches) { this.platform.log.info(`Changing mode to turbo for device: ${this.device.deviceName}`); let ok = false; if (typeof extendedDevice.turboMode === 'function') { ok = await extendedDevice.turboMode(); } else if (typeof extendedDevice.setMode === 'function') { ok = await extendedDevice.setMode('turbo'); } if (!ok) { throw new Error('Failed to set turbo mode'); } this.service.updateCharacteristic(this.platform.Characteristic.RotationSpeed, 100); this.service.updateCharacteristic(this.platform.Characteristic.Active, 1); this.service.updateCharacteristic(this.platform.Characteristic.CurrentAirPurifierState, 2); this.syncTargetStateToHomeKit('turbo'); this.lastSetSpeed = max; this.lastSetPercentage = 100; this.skipNextUpdate = true; return; } // Map manual notches (2..manualMaxNotch) to speeds 1..max const manualMaxNotch = totalNotches - (supportsTurbo ? 1 : 0); const manualNotch = Math.max(2, Math.min(manualMaxNotch, notch)); const desiredSpeed = Math.min(max, manualNotch - 1); // Ensure manual mode before speed change if necessary const modeNow = (extendedDevice.mode || '').toLowerCase(); if (modeNow === 'auto' || modeNow === 'sleep' || (supportsTurbo && modeNow === 'turbo')) { this.platform.log.info(`Changing mode to manual for device: ${this.device.deviceName}`); let modeOk = false; if (typeof extendedDevice.manualMode === 'function') { modeOk = await extendedDevice.manualMode(); } else if (typeof extendedDevice.setMode === 'function') { modeOk = await extendedDevice.setMode('manual'); } if (!modeOk) { // Don't silently push a speed at a device still sitting in auto throw new Error('Failed to set manual mode'); } } // Update UI early, echoing the controller's own value this.service.updateCharacteristic(this.platform.Characteristic.RotationSpeed, echoPct); this.syncTargetStateToHomeKit('manual'); this.lastSetSpeed = desiredSpeed; this.lastSetPercentage = echoPct; this.skipNextUpdate = true; const ok = await this.device.changeFanSpeed(desiredSpeed); if (!ok) { this.lastSetSpeed = 0; this.lastSetPercentage = 0; throw new Error(`Failed to set speed to ${desiredSpeed}`); } // The library leaves its cache stale for these models - fix it up so // the slider doesn't snap back to the previously reported speed. this.cacheCommandedSpeed(desiredSpeed, 'manual'); return; } // 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'}`); // Wait for mode change to take effect await new Promise(resolve => setTimeout(resolve, 1000)); } else if (typeof extendedDevice.setMode === 'function') { const modeSuccess = await extendedDevice.setMode('manual'); this.platform.log.debug(`Set manual mode result: ${modeSuccess ? 'success' : 'failed'}`); // Wait for mode change to take effect await new Promise(resolve => setTimeout(resolve, 1000)); } } } // For devices without sleep notch, ensure manual mode if required const modeNow = (extendedDevice.mode || '').toLowerCase(); if (modeNow === 'auto') { let modeOk = false; if (typeof extendedDevice.manualMode === 'function') { modeOk = await extendedDevice.manualMode(); } else if (typeof extendedDevice.setMode === 'function') { modeOk = await extendedDevice.setMode('manual'); } if (!modeOk) { throw new Error('Failed to set manual mode'); } // Reflect the mode change in HomeKit right away this.syncTargetStateToHomeKit('manual'); } // 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.cacheCommandedSpeed(speed, 'manual'); 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 { return this.device.deviceStatus === 'on' ? 1 : 0; } /** * Set active state. Buffered so it can be reconciled with any * RotationSpeed/TargetAirPurifierState writes sent in the same HomeKit * request - notably so a 0% slider can't turn the device straight back off. */ private async setActive(value: CharacteristicValue): Promise { return this.queueWrite('active', value as number === 1 ? 1 : 0); } private async applyActive(value: CharacteristicValue): Promise { try { const isOn = value as number === 1; this.platform.log.debug(`Setting device ${this.device.deviceName} to ${isOn ? 'on' : 'off'}`); // Immediately update HomeKit characteristics for better responsiveness // This provides instant feedback to the user while the API call is made this.service.updateCharacteristic(this.platform.Characteristic.Active, isOn ? 1 : 0); this.service.updateCharacteristic( this.platform.Characteristic.CurrentAirPurifierState, isOn ? 2 : 0 // 2 = PURIFYING_AIR, 0 = INACTIVE ); const success = isOn ? await this.device.turnOn() : await this.device.turnOff(); if (!success) { // For LV-PUR131S, success=false might mean device is already in desired state if (this.isAir131Device) { this.platform.log.debug(`${this.device.deviceName}: turn${isOn ? 'On' : 'Off'}() returned false - device may already be ${isOn ? 'on' : 'off'}`); // Check actual device state const actualState = this.device.deviceStatus === 'on'; if (actualState === isOn) { this.platform.log.debug(`${this.device.deviceName}: Device is already in desired state (${isOn ? 'on' : 'off'})`); return; // Don't throw error if already in desired state } } // If the API call failed, revert the HomeKit state this.platform.log.warn(`Failed to turn ${isOn ? 'on' : 'off'} device, reverting HomeKit state`); this.service.updateCharacteristic(this.platform.Characteristic.Active, isOn ? 0 : 1); this.service.updateCharacteristic( this.platform.Characteristic.CurrentAirPurifierState, isOn ? 0 : 2 ); throw new Error(`Failed to turn ${isOn ? 'on' : 'off'} device`); } // Reset tracking variables when turning off if (!isOn) { this.lastSetSpeed = 0; this.lastSetPercentage = 0; // Also immediately update rotation speed to 0 this.service.updateCharacteristic(this.platform.Characteristic.RotationSpeed, 0); } else if (isOn) { // For all air purifiers when turning on, restore the rotation speed if (this.isAir131Device) { // For Air131 devices, immediately update rotation speed characteristic // to show the restored speed instead of 0 await this.updateAir131RotationSpeedAfterTurnOn(); } else if (this.device.speed && this.device.speed > 0) { // For other devices, also update the speed immediately const percentage = this.speedToPercentage(this.device.speed); this.service.updateCharacteristic(this.platform.Characteristic.RotationSpeed, percentage); } } // Don't call updateDeviceSpecificStates to avoid polling the API // The device state is already reflected in HomeKit through our immediate updates // await this.updateDeviceSpecificStates(this.device); // Still persist the state for recovery after restarts await this.persistDeviceState('deviceStatus', isOn ? 'on' : 'off'); this.platform.log.debug(`${this.device.deviceName}: Successfully turned ${isOn ? 'on' : 'off'} with immediate HomeKit feedback`); } catch (error) { this.handleDeviceError('set active state', error); } } }