homebridge-tsvesync
Version:
Homebridge plugin for VeSync devices including Levoit air purifiers, humidifiers, and Etekcity smart outlets
835 lines (703 loc) • 26 kB
text/typescript
import { CharacteristicValue, PlatformAccessory, Service } from 'homebridge';
import { BaseAccessory } from './base.accessory';
import { TSVESyncPlatform } from '../platform';
import { DeviceCapabilities, VeSyncBulb, VeSyncDimmerSwitch, VeSyncLightDevice } from '../types/device.types';
// 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: number, min: number, max: number): number => Math.min(Math.max(value, min), max);
const clampMired = (value: number): number => clamp(Math.round(value), MIN_COLOR_TEMP, MAX_COLOR_TEMP);
const percentToKelvin = (percent: number): number => DEVICE_MIN_KELVIN + ((DEVICE_MAX_KELVIN - DEVICE_MIN_KELVIN) * clamp(percent, 0, 100) / 100);
const kelvinToPercent = (kelvin: number): number => ((clamp(kelvin, DEVICE_MIN_KELVIN, DEVICE_MAX_KELVIN) - DEVICE_MIN_KELVIN) / (DEVICE_MAX_KELVIN - DEVICE_MIN_KELVIN)) * 100;
const kelvinToMired = (kelvin: number): number => Math.round(1_000_000 / clamp(kelvin, DEVICE_MIN_KELVIN, DEVICE_MAX_KELVIN));
const miredToKelvin = (mired: number): number => clamp(Math.round(1_000_000 / clamp(mired, MIN_COLOR_TEMP, MAX_COLOR_TEMP)), DEVICE_MIN_KELVIN, DEVICE_MAX_KELVIN);
const percentToMired = (percent: number): number => clampMired(kelvinToMired(percentToKelvin(percent)));
const miredToPercent = (mired: number): number => kelvinToPercent(miredToKelvin(mired));
export class LightAccessory extends BaseAccessory {
protected readonly device: VeSyncLightDevice;
private capabilities!: DeviceCapabilities;
private isDimmerDevice = false;
private indicatorService?: Service;
private indicatorColorState = { hue: 0, saturation: 0 };
private indicatorColorUpdateTimeout?: NodeJS.Timeout;
private lastKnownDimmerBrightness = 100;
private lastDimmerRefresh = 0;
private static readonly DIMMER_REFRESH_DEBOUNCE_MS = 2000;
constructor(
platform: TSVESyncPlatform,
accessory: PlatformAccessory,
device: VeSyncLightDevice
) {
super(platform, accessory, device);
this.device = device;
const initialBrightness = Number((device as any)?.brightness);
if (!Number.isNaN(initialBrightness) && initialBrightness > 0) {
this.lastKnownDimmerBrightness = Math.min(100, Math.max(1, initialBrightness));
}
}
protected setupService(): void {
// 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();
}
}
private setupColorTemperature(): void {
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)
);
}
private setupColor(): void {
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
*/
protected async updateDeviceSpecificStates(details: any): Promise<void> {
const lightDetails = details as VeSyncLightDevice & {
colorTemp?: number;
hue?: number;
saturation?: number;
};
const deviceStatus = lightDetails.deviceStatus ?? this.device.deviceStatus;
const isActive = deviceStatus === 'on';
this.updateCharacteristicValue(
this.platform.Characteristic.On,
isActive
);
// Update brightness if supported
if (this.capabilities.hasBrightness) {
let brightness: number | undefined = lightDetails.brightness;
if (!this.isDimmerDevice) {
const bulb = this.device as VeSyncBulb;
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 as VeSyncBulb;
let tempPercent: number | undefined;
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 as VeSyncBulb;
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 as VeSyncDimmerSwitch;
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);
}
}
protected getDeviceCapabilities(): DeviceCapabilities {
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 as VeSyncBulb;
const supports = typeof bulb.hasFeature === 'function'
? bulb.hasFeature.bind(bulb)
: (feature: string): boolean => {
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 as any).setBrightness === 'function',
hasColorTemp: supports('color_temp'),
hasColor: supports('rgb_shift'),
hasSpeed: false,
hasHumidity: false,
hasAirQuality: false,
hasWaterLevel: false,
hasChildLock: false,
hasSwingMode: false,
};
}
private async getOn(): Promise<CharacteristicValue> {
return this.device.deviceStatus === 'on';
}
private async setOn(value: CharacteristicValue): Promise<void> {
try {
const isOn = value as boolean;
if (this.isDimmerDevice) {
const dimmer = this.device as VeSyncDimmerSwitch;
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);
}
}
private async getBrightness(): Promise<CharacteristicValue> {
const deviceWithGetter = this.device as VeSyncDimmerSwitch & { getBrightness?: () => number };
if (typeof deviceWithGetter.getBrightness === 'function') {
return deviceWithGetter.getBrightness();
}
return this.device.brightness;
}
private async setBrightness(value: CharacteristicValue): Promise<void> {
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 as VeSyncDimmerSwitch).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);
}
}
private async getColorTemperature(): Promise<CharacteristicValue> {
if (this.isDimmerDevice) {
return DEFAULT_COLOR_TEMP;
}
const bulb = this.device as VeSyncBulb;
if (typeof bulb.getColorTempPercent === 'function') {
const percent = bulb.getColorTempPercent();
if (percent > 0) {
return percentToMired(percent);
}
}
const stored = this.accessory.context?.device?.details?.colorTemp;
if (typeof stored === 'number') {
return clampMired(stored);
}
return DEFAULT_COLOR_TEMP;
}
private async setColorTemperature(value: CharacteristicValue): Promise<void> {
try {
if (this.isDimmerDevice) {
throw new Error('Device does not support color temperature');
}
const bulb = this.device as VeSyncBulb;
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);
}
}
private async getHue(): Promise<CharacteristicValue> {
if (this.isDimmerDevice) {
return 0;
}
const bulb = this.device as VeSyncBulb;
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;
}
private async getSaturation(): Promise<CharacteristicValue> {
if (this.isDimmerDevice) {
return 0;
}
const bulb = this.device as VeSyncBulb;
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;
}
private async setHue(value: CharacteristicValue): Promise<void> {
try {
if (this.isDimmerDevice) {
throw new Error('Device does not support color');
}
const bulb = this.device as VeSyncBulb;
if (!bulb.setColor) {
throw new Error('Device does not support color');
}
const fallbackSaturation = this.service.getCharacteristic(this.platform.Characteristic.Saturation).value as number | undefined;
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);
}
}
private async setSaturation(value: CharacteristicValue): Promise<void> {
try {
if (this.isDimmerDevice) {
throw new Error('Device does not support color');
}
const bulb = this.device as VeSyncBulb;
if (!bulb.setColor) {
throw new Error('Device does not support color');
}
const fallbackHue = this.service.getCharacteristic(this.platform.Characteristic.Hue).value as number | undefined;
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);
}
}
private detectDimmer(device: VeSyncLightDevice): device is VeSyncDimmerSwitch {
return typeof (device as VeSyncDimmerSwitch).rgbColorSet === 'function';
}
private supportsIndicatorLight(): boolean {
const model = this.device.deviceType.toUpperCase();
if (model.startsWith('ESWD16')) {
return false;
}
return typeof (this.device as VeSyncDimmerSwitch).rgbColorSet === 'function';
}
private setupIndicatorService(): void {
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)
);
}
private setupCharacteristicForService(
service: Service,
characteristic: any,
onGet?: () => Promise<CharacteristicValue>,
onSet?: (value: CharacteristicValue) => Promise<void>
): void {
this.withService(service, () => {
this.setupCharacteristic(characteristic, onGet, onSet);
});
}
private updateIndicatorCharacteristic(
characteristic: any,
value: CharacteristicValue
): void {
if (!this.indicatorService) {
return;
}
this.withService(this.indicatorService, () => {
this.updateCharacteristicValue(characteristic, value);
});
}
private withService<T>(service: Service, fn: () => T): T {
const currentService = this.service;
this.service = service;
try {
return fn();
} finally {
this.service = currentService;
}
}
private async getIndicatorOn(): Promise<CharacteristicValue> {
if (!this.isDimmerDevice) {
return false;
}
const dimmer = this.device as VeSyncDimmerSwitch;
return dimmer.rgbLightStatus === 'on';
}
private async setIndicatorOn(value: CharacteristicValue): Promise<void> {
if (!this.isDimmerDevice) {
return;
}
const isOn = Boolean(value);
const dimmer = this.device as VeSyncDimmerSwitch;
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();
}
private async getIndicatorHue(): Promise<CharacteristicValue> {
if (!this.isDimmerDevice) {
return 0;
}
const { hue } = this.rgbToHsv(
(this.device as VeSyncDimmerSwitch).rgbLightValue.red,
(this.device as VeSyncDimmerSwitch).rgbLightValue.green,
(this.device as VeSyncDimmerSwitch).rgbLightValue.blue
);
this.indicatorColorState.hue = hue;
return hue;
}
private async setIndicatorHue(value: CharacteristicValue): Promise<void> {
if (!this.isDimmerDevice) {
return;
}
this.indicatorColorState.hue = Number(value);
this.scheduleIndicatorColorUpdate();
}
private async getIndicatorSaturation(): Promise<CharacteristicValue> {
if (!this.isDimmerDevice) {
return 0;
}
const { saturation } = this.rgbToHsv(
(this.device as VeSyncDimmerSwitch).rgbLightValue.red,
(this.device as VeSyncDimmerSwitch).rgbLightValue.green,
(this.device as VeSyncDimmerSwitch).rgbLightValue.blue
);
this.indicatorColorState.saturation = saturation;
return saturation;
}
private async setIndicatorSaturation(value: CharacteristicValue): Promise<void> {
if (!this.isDimmerDevice) {
return;
}
this.indicatorColorState.saturation = Number(value);
this.scheduleIndicatorColorUpdate();
}
private scheduleIndicatorColorUpdate(): void {
if (this.indicatorColorUpdateTimeout) {
clearTimeout(this.indicatorColorUpdateTimeout);
}
this.indicatorColorUpdateTimeout = setTimeout(() => {
void this.pushIndicatorColor().catch(error => {
void this.handleDeviceError('set indicator color', error);
});
}, 250);
}
private async pushIndicatorColor(): Promise<void> {
if (!this.isDimmerDevice) {
return;
}
const [red, green, blue] = this.hsvToRgb(
this.indicatorColorState.hue,
this.indicatorColorState.saturation,
100
);
const dimmer = this.device as VeSyncDimmerSwitch;
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();
}
private hsvToRgb(h: number, s: number, v: number): [number, number, number] {
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];
}
private rgbToHsv(r: number, g: number, b: number): { hue: number; saturation: number; value: number } {
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),
};
}
private async refreshDimmerDetails(): Promise<void> {
if (!this.isDimmerDevice) {
return;
}
const dimmer = this.device as VeSyncDimmerSwitch;
try {
const now = Date.now();
if (
now - this.lastDimmerRefresh >= LightAccessory.DIMMER_REFRESH_DEBOUNCE_MS &&
typeof (dimmer as any).getDetails === 'function'
) {
await (dimmer as any).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))
);
}
}
private resolveDimmerOnBrightness(): number {
const dimmer = this.device as VeSyncDimmerSwitch;
const current = Number((dimmer as any).brightness);
if (!Number.isNaN(current) && current > 0) {
this.lastKnownDimmerBrightness = Math.min(100, Math.max(1, current));
return this.lastKnownDimmerBrightness;
}
const persisted = Number(this.accessory.context?.device?.details?.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));
}
}