UNPKG

homebridge-tsvesync

Version:

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

623 lines 26.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.LightAccessory = void 0; const base_accessory_1 = require("./base.accessory"); // Constants for color temperature const MIN_COLOR_TEMP = 140; // 7143K (cool) const MAX_COLOR_TEMP = 500; // 2000K (warm) const DEFAULT_COLOR_TEMP = MIN_COLOR_TEMP; const DEVICE_MIN_KELVIN = 2700; const DEVICE_MAX_KELVIN = 6500; const clamp = (value, min, max) => Math.min(Math.max(value, min), max); const clampMired = (value) => clamp(Math.round(value), MIN_COLOR_TEMP, MAX_COLOR_TEMP); const percentToKelvin = (percent) => DEVICE_MIN_KELVIN + ((DEVICE_MAX_KELVIN - DEVICE_MIN_KELVIN) * clamp(percent, 0, 100) / 100); const kelvinToPercent = (kelvin) => ((clamp(kelvin, DEVICE_MIN_KELVIN, DEVICE_MAX_KELVIN) - DEVICE_MIN_KELVIN) / (DEVICE_MAX_KELVIN - DEVICE_MIN_KELVIN)) * 100; const kelvinToMired = (kelvin) => Math.round(1000000 / clamp(kelvin, DEVICE_MIN_KELVIN, DEVICE_MAX_KELVIN)); const miredToKelvin = (mired) => clamp(Math.round(1000000 / clamp(mired, MIN_COLOR_TEMP, MAX_COLOR_TEMP)), DEVICE_MIN_KELVIN, DEVICE_MAX_KELVIN); const percentToMired = (percent) => clampMired(kelvinToMired(percentToKelvin(percent))); const miredToPercent = (mired) => kelvinToPercent(miredToKelvin(mired)); class LightAccessory extends base_accessory_1.BaseAccessory { constructor(platform, accessory, device) { super(platform, accessory, device); this.isDimmerDevice = false; this.indicatorColorState = { hue: 0, saturation: 0 }; this.lastKnownDimmerBrightness = 100; this.lastDimmerRefresh = 0; this.device = device; const initialBrightness = Number(device === null || device === void 0 ? void 0 : device.brightness); if (!Number.isNaN(initialBrightness) && initialBrightness > 0) { this.lastKnownDimmerBrightness = Math.min(100, Math.max(1, initialBrightness)); } } setupService() { // Get or create the light service this.service = this.accessory.getService(this.platform.Service.Lightbulb) || this.accessory.addService(this.platform.Service.Lightbulb); // Set up required characteristics this.setupCharacteristic(this.platform.Characteristic.On, this.getOn.bind(this), this.setOn.bind(this)); // Set up optional characteristics based on device capabilities this.isDimmerDevice = this.detectDimmer(this.device); this.capabilities = this.getDeviceCapabilities(); const capabilities = this.capabilities; if (capabilities.hasBrightness) { this.setupCharacteristic(this.platform.Characteristic.Brightness, this.getBrightness.bind(this), this.setBrightness.bind(this)); } if (capabilities.hasColorTemp) { this.setupColorTemperature(); } if (capabilities.hasColor) { this.setupColor(); } // Add Name characteristic this.setupCharacteristic(this.platform.Characteristic.Name, async () => this.device.deviceName); if (this.isDimmerDevice && this.supportsIndicatorLight()) { this.setupIndicatorService(); } } setupColorTemperature() { this.service.getCharacteristic(this.platform.Characteristic.ColorTemperature) .setProps({ minValue: MIN_COLOR_TEMP, maxValue: MAX_COLOR_TEMP, }); this.setupCharacteristic(this.platform.Characteristic.ColorTemperature, this.getColorTemperature.bind(this), this.setColorTemperature.bind(this)); } setupColor() { this.setupCharacteristic(this.platform.Characteristic.Hue, this.getHue.bind(this), this.setHue.bind(this)); this.setupCharacteristic(this.platform.Characteristic.Saturation, this.getSaturation.bind(this), this.setSaturation.bind(this)); } /** * Update device states based on the latest details */ async updateDeviceSpecificStates(details) { var _a; const lightDetails = details; const deviceStatus = (_a = lightDetails.deviceStatus) !== null && _a !== void 0 ? _a : this.device.deviceStatus; const isActive = deviceStatus === 'on'; this.updateCharacteristicValue(this.platform.Characteristic.On, isActive); // Update brightness if supported if (this.capabilities.hasBrightness) { let brightness = lightDetails.brightness; if (!this.isDimmerDevice) { const bulb = this.device; if (typeof bulb.getBrightness === 'function') { brightness = bulb.getBrightness(); } } if (brightness !== undefined) { this.updateCharacteristicValue(this.platform.Characteristic.Brightness, brightness); if (this.isDimmerDevice && brightness > 0) { this.lastKnownDimmerBrightness = brightness; } } } // Update color temperature if supported if (this.capabilities.hasColorTemp && !this.isDimmerDevice) { const bulb = this.device; let tempPercent; if (typeof bulb.getColorTempPercent === 'function') { tempPercent = bulb.getColorTempPercent(); } else if (typeof lightDetails.colorTemp === 'number') { tempPercent = lightDetails.colorTemp > 100 ? miredToPercent(lightDetails.colorTemp) : lightDetails.colorTemp; } if (typeof tempPercent === 'number' && !Number.isNaN(tempPercent) && tempPercent > 0) { const mired = percentToMired(tempPercent); this.updateCharacteristicValue(this.platform.Characteristic.ColorTemperature, mired); } } // Update color if supported if (this.capabilities.hasColor && !this.isDimmerDevice) { const bulb = this.device; const colorModel = typeof bulb.getColorModel === 'function' ? bulb.getColorModel() : 'none'; if (colorModel === 'rgb' && typeof bulb.getRGBValues === 'function') { const rgb = bulb.getRGBValues(); const hsv = this.rgbToHsv(rgb.red, rgb.green, rgb.blue); this.updateCharacteristicValue(this.platform.Characteristic.Hue, hsv.hue); this.updateCharacteristicValue(this.platform.Characteristic.Saturation, hsv.saturation); } else { const hue = typeof bulb.getColorHue === 'function' ? bulb.getColorHue() : lightDetails.hue; const saturation = typeof bulb.getColorSaturation === 'function' ? bulb.getColorSaturation() : lightDetails.saturation; if (typeof hue === 'number') { this.updateCharacteristicValue(this.platform.Characteristic.Hue, hue); } if (typeof saturation === 'number') { this.updateCharacteristicValue(this.platform.Characteristic.Saturation, saturation); } } } if (this.isDimmerDevice && this.indicatorService) { const dimmer = lightDetails; const isRgbOn = dimmer.rgbLightStatus === 'on'; this.updateIndicatorCharacteristic(this.platform.Characteristic.On, isRgbOn); const { hue, saturation } = this.rgbToHsv(dimmer.rgbLightValue.red, dimmer.rgbLightValue.green, dimmer.rgbLightValue.blue); this.indicatorColorState = { hue, saturation }; this.updateIndicatorCharacteristic(this.platform.Characteristic.Hue, hue); this.updateIndicatorCharacteristic(this.platform.Characteristic.Saturation, saturation); } } getDeviceCapabilities() { if (this.isDimmerDevice) { return { hasBrightness: true, hasColorTemp: false, hasColor: false, hasSpeed: false, hasHumidity: false, hasAirQuality: false, hasWaterLevel: false, hasChildLock: false, hasSwingMode: false, }; } const model = this.device.deviceType.toUpperCase(); const bulb = this.device; const supports = typeof bulb.hasFeature === 'function' ? bulb.hasFeature.bind(bulb) : (feature) => { if (feature === 'color_temp') { return model.includes('CW') || model.includes('MC') || model === 'XYD0001'; } if (feature === 'rgb_shift') { return model.includes('MC') || model === 'XYD0001'; } if (feature === 'dimmable') { return true; } return false; }; return { hasBrightness: typeof this.device.setBrightness === 'function', hasColorTemp: supports('color_temp'), hasColor: supports('rgb_shift'), hasSpeed: false, hasHumidity: false, hasAirQuality: false, hasWaterLevel: false, hasChildLock: false, hasSwingMode: false, }; } async getOn() { return this.device.deviceStatus === 'on'; } async setOn(value) { try { const isOn = value; if (this.isDimmerDevice) { const dimmer = this.device; if (isOn) { const toggled = await dimmer.turnOn(); if (!toggled) { throw new Error('Failed to turn on device'); } const targetBrightness = this.resolveDimmerOnBrightness(); if (targetBrightness > 0) { const brightnessSet = await dimmer.setBrightness(targetBrightness); if (!brightnessSet) { throw new Error(`Failed to restore brightness to ${targetBrightness}`); } this.lastKnownDimmerBrightness = targetBrightness; this.updateCharacteristicValue(this.platform.Characteristic.Brightness, targetBrightness); await this.persistDeviceState('brightness', targetBrightness); } } else { const toggled = await dimmer.turnOff(); if (!toggled) { throw new Error('Failed to turn off device'); } this.updateCharacteristicValue(this.platform.Characteristic.Brightness, 0); await this.persistDeviceState('brightness', 0); } await this.persistDeviceState('deviceStatus', isOn ? 'on' : 'off'); await this.refreshDimmerDetails(); return; } const success = isOn ? await this.device.turnOn() : await this.device.turnOff(); if (!success) { throw new Error(`Failed to turn ${isOn ? 'on' : 'off'} device`); } await this.persistDeviceState('deviceStatus', isOn ? 'on' : 'off'); } catch (error) { this.handleDeviceError('set on state', error); } } async getBrightness() { const deviceWithGetter = this.device; if (typeof deviceWithGetter.getBrightness === 'function') { return deviceWithGetter.getBrightness(); } return this.device.brightness; } async setBrightness(value) { try { const target = Math.round(Number(value)); const brightness = Math.min(100, Math.max(this.isDimmerDevice ? 0 : 0, target)); let success = await this.device.setBrightness(brightness); if (!success && this.isDimmerDevice && brightness === 0) { const fallback = await this.device.turnOff(); if (!fallback) { throw new Error('Failed to turn off device when setting brightness to 0'); } success = true; } if (!success) { throw new Error(`Failed to set brightness to ${brightness}`); } await this.persistDeviceState('brightness', brightness); if (this.isDimmerDevice) { if (brightness > 0) { this.lastKnownDimmerBrightness = brightness; await this.persistDeviceState('deviceStatus', 'on'); } else { await this.persistDeviceState('deviceStatus', 'off'); } await this.refreshDimmerDetails(); } } catch (error) { this.handleDeviceError('set brightness', error); } } async getColorTemperature() { var _a, _b, _c; if (this.isDimmerDevice) { return DEFAULT_COLOR_TEMP; } const bulb = this.device; if (typeof bulb.getColorTempPercent === 'function') { const percent = bulb.getColorTempPercent(); if (percent > 0) { return percentToMired(percent); } } const stored = (_c = (_b = (_a = this.accessory.context) === null || _a === void 0 ? void 0 : _a.device) === null || _b === void 0 ? void 0 : _b.details) === null || _c === void 0 ? void 0 : _c.colorTemp; if (typeof stored === 'number') { return clampMired(stored); } return DEFAULT_COLOR_TEMP; } async setColorTemperature(value) { try { if (this.isDimmerDevice) { throw new Error('Device does not support color temperature'); } const bulb = this.device; if (!bulb.setColorTemperature) { throw new Error('Device does not support color temperature'); } const percent = miredToPercent(Number(value)); const success = await bulb.setColorTemperature(percent); if (!success) { throw new Error(`Failed to set color temperature to ${value}`); } await this.persistDeviceState('colorTemp', value); } catch (error) { this.handleDeviceError('set color temperature', error); } } async getHue() { if (this.isDimmerDevice) { return 0; } const bulb = this.device; const colorModel = typeof bulb.getColorModel === 'function' ? bulb.getColorModel() : 'none'; if (colorModel === 'rgb' && typeof bulb.getRGBValues === 'function') { const rgb = bulb.getRGBValues(); return this.rgbToHsv(rgb.red, rgb.green, rgb.blue).hue; } if (typeof bulb.getColorHue === 'function') { return bulb.getColorHue(); } return 0; } async getSaturation() { if (this.isDimmerDevice) { return 0; } const bulb = this.device; const colorModel = typeof bulb.getColorModel === 'function' ? bulb.getColorModel() : 'none'; if (colorModel === 'rgb' && typeof bulb.getRGBValues === 'function') { const rgb = bulb.getRGBValues(); return this.rgbToHsv(rgb.red, rgb.green, rgb.blue).saturation; } if (typeof bulb.getColorSaturation === 'function') { return bulb.getColorSaturation(); } return 0; } async setHue(value) { try { if (this.isDimmerDevice) { throw new Error('Device does not support color'); } const bulb = this.device; if (!bulb.setColor) { throw new Error('Device does not support color'); } const fallbackSaturation = this.service.getCharacteristic(this.platform.Characteristic.Saturation).value; const currentSaturation = typeof bulb.getColorSaturation === 'function' ? bulb.getColorSaturation() : (typeof fallbackSaturation === 'number' ? fallbackSaturation : 0); const currentValue = typeof bulb.getColorValue === 'function' ? bulb.getColorValue() : 100; const success = await bulb.setColor(Number(value), currentSaturation || 0, currentValue || 100); if (!success) { throw new Error(`Failed to set hue to ${value}`); } await this.persistDeviceState('hue', value); } catch (error) { this.handleDeviceError('set hue', error); } } async setSaturation(value) { try { if (this.isDimmerDevice) { throw new Error('Device does not support color'); } const bulb = this.device; if (!bulb.setColor) { throw new Error('Device does not support color'); } const fallbackHue = this.service.getCharacteristic(this.platform.Characteristic.Hue).value; const currentHue = typeof bulb.getColorHue === 'function' ? bulb.getColorHue() : (typeof fallbackHue === 'number' ? fallbackHue : 0); const currentValue = typeof bulb.getColorValue === 'function' ? bulb.getColorValue() : 100; const success = await bulb.setColor(currentHue || 0, Number(value), currentValue || 100); if (!success) { throw new Error(`Failed to set saturation to ${value}`); } await this.persistDeviceState('saturation', value); } catch (error) { this.handleDeviceError('set saturation', error); } } detectDimmer(device) { return typeof device.rgbColorSet === 'function'; } supportsIndicatorLight() { const model = this.device.deviceType.toUpperCase(); if (model.startsWith('ESWD16')) { return false; } return typeof this.device.rgbColorSet === 'function'; } setupIndicatorService() { const serviceName = `${this.device.deviceName} Indicator`; const existing = this.accessory.getService(serviceName); this.indicatorService = existing || this.accessory.addService(this.platform.Service.Lightbulb, serviceName, 'indicator'); const indicatorService = this.indicatorService; if (!indicatorService) { return; } this.setupCharacteristicForService(indicatorService, this.platform.Characteristic.On, this.getIndicatorOn.bind(this), this.setIndicatorOn.bind(this)); this.setupCharacteristicForService(indicatorService, this.platform.Characteristic.Hue, this.getIndicatorHue.bind(this), this.setIndicatorHue.bind(this)); this.setupCharacteristicForService(indicatorService, this.platform.Characteristic.Saturation, this.getIndicatorSaturation.bind(this), this.setIndicatorSaturation.bind(this)); } setupCharacteristicForService(service, characteristic, onGet, onSet) { this.withService(service, () => { this.setupCharacteristic(characteristic, onGet, onSet); }); } updateIndicatorCharacteristic(characteristic, value) { if (!this.indicatorService) { return; } this.withService(this.indicatorService, () => { this.updateCharacteristicValue(characteristic, value); }); } withService(service, fn) { const currentService = this.service; this.service = service; try { return fn(); } finally { this.service = currentService; } } async getIndicatorOn() { if (!this.isDimmerDevice) { return false; } const dimmer = this.device; return dimmer.rgbLightStatus === 'on'; } async setIndicatorOn(value) { if (!this.isDimmerDevice) { return; } const isOn = Boolean(value); const dimmer = this.device; const success = isOn ? await dimmer.rgbColorOn() : await dimmer.rgbColorOff(); if (!success) { throw new Error(`Failed to turn ${isOn ? 'on' : 'off'} indicator light`); } await this.refreshDimmerDetails(); } async getIndicatorHue() { if (!this.isDimmerDevice) { return 0; } const { hue } = this.rgbToHsv(this.device.rgbLightValue.red, this.device.rgbLightValue.green, this.device.rgbLightValue.blue); this.indicatorColorState.hue = hue; return hue; } async setIndicatorHue(value) { if (!this.isDimmerDevice) { return; } this.indicatorColorState.hue = Number(value); this.scheduleIndicatorColorUpdate(); } async getIndicatorSaturation() { if (!this.isDimmerDevice) { return 0; } const { saturation } = this.rgbToHsv(this.device.rgbLightValue.red, this.device.rgbLightValue.green, this.device.rgbLightValue.blue); this.indicatorColorState.saturation = saturation; return saturation; } async setIndicatorSaturation(value) { if (!this.isDimmerDevice) { return; } this.indicatorColorState.saturation = Number(value); this.scheduleIndicatorColorUpdate(); } scheduleIndicatorColorUpdate() { if (this.indicatorColorUpdateTimeout) { clearTimeout(this.indicatorColorUpdateTimeout); } this.indicatorColorUpdateTimeout = setTimeout(() => { void this.pushIndicatorColor().catch(error => { void this.handleDeviceError('set indicator color', error); }); }, 250); } async pushIndicatorColor() { if (!this.isDimmerDevice) { return; } const [red, green, blue] = this.hsvToRgb(this.indicatorColorState.hue, this.indicatorColorState.saturation, 100); const dimmer = this.device; if (dimmer.rgbLightStatus !== 'on') { await dimmer.rgbColorOn(); } const success = await dimmer.rgbColorSet(red, green, blue); if (!success) { throw new Error('Failed to update indicator color'); } this.indicatorColorState = { hue: this.indicatorColorState.hue, saturation: this.indicatorColorState.saturation, }; await this.refreshDimmerDetails(); } hsvToRgb(h, s, v) { let hue = h % 360; if (hue < 0) { hue += 360; } const saturation = Math.max(0, Math.min(100, s)) / 100; const value = Math.max(0, Math.min(100, v)) / 100; const c = value * saturation; const x = c * (1 - Math.abs(((hue / 60) % 2) - 1)); const m = value - c; let rPrime = 0; let gPrime = 0; let bPrime = 0; if (hue < 60) { rPrime = c; gPrime = x; } else if (hue < 120) { rPrime = x; gPrime = c; } else if (hue < 180) { gPrime = c; bPrime = x; } else if (hue < 240) { gPrime = x; bPrime = c; } else if (hue < 300) { rPrime = x; bPrime = c; } else { rPrime = c; bPrime = x; } const red = Math.round((rPrime + m) * 255); const green = Math.round((gPrime + m) * 255); const blue = Math.round((bPrime + m) * 255); return [red, green, blue]; } rgbToHsv(r, g, b) { const red = r / 255; const green = g / 255; const blue = b / 255; const max = Math.max(red, green, blue); const min = Math.min(red, green, blue); const delta = max - min; let hue = 0; if (delta !== 0) { if (max === red) { hue = ((green - blue) / delta) % 6; } else if (max === green) { hue = (blue - red) / delta + 2; } else { hue = (red - green) / delta + 4; } hue *= 60; if (hue < 0) { hue += 360; } } const saturation = max === 0 ? 0 : (delta / max) * 100; const value = max * 100; return { hue: Math.round(hue), saturation: Math.round(saturation), value: Math.round(value), }; } async refreshDimmerDetails() { if (!this.isDimmerDevice) { return; } const dimmer = this.device; try { const now = Date.now(); if (now - this.lastDimmerRefresh >= LightAccessory.DIMMER_REFRESH_DEBOUNCE_MS && typeof dimmer.getDetails === 'function') { await dimmer.getDetails(); this.lastDimmerRefresh = now; } } catch (error) { this.platform.log.debug(`${this.device.deviceName}: failed to refresh details after state change`, error instanceof Error ? error : new Error(String(error))); } } resolveDimmerOnBrightness() { var _a, _b, _c; const dimmer = this.device; const current = Number(dimmer.brightness); if (!Number.isNaN(current) && current > 0) { this.lastKnownDimmerBrightness = Math.min(100, Math.max(1, current)); return this.lastKnownDimmerBrightness; } const persisted = Number((_c = (_b = (_a = this.accessory.context) === null || _a === void 0 ? void 0 : _a.device) === null || _b === void 0 ? void 0 : _b.details) === null || _c === void 0 ? void 0 : _c.brightness); if (!Number.isNaN(persisted) && persisted > 0) { this.lastKnownDimmerBrightness = Math.min(100, Math.max(1, persisted)); return this.lastKnownDimmerBrightness; } return Math.min(100, Math.max(1, this.lastKnownDimmerBrightness)); } } exports.LightAccessory = LightAccessory; LightAccessory.DIMMER_REFRESH_DEBOUNCE_MS = 2000; //# sourceMappingURL=light.accessory.js.map