diff --git a/CHANGELOG.md b/CHANGELOG.md index 00fce382..79bf5a01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ Since version 1.0.0, we try to follow the [Semantic Versioning](https://semver.o Set minimum value for _Ambient Light Level_ to 0, if range is not provided. (see [#235](https://github.com/itavero/homebridge-z2m/issues/235)) - Remove (top level) items with an undefined/null value from state updates. This should prevent the warnings mentioned in [#234](https://github.com/itavero/homebridge-z2m/issues/234). +- `light`: filter properties in state update based on `color_mode`, if provided. (see [#208](https://github.com/itavero/homebridge-z2m/issues/208)) +- `light`: set Hue/Saturation based on Color Temperature (if `color_mode` is also received), to slightly improve the UX. Unfortunately the translation is far from perfect at the moment. (see [#208](https://github.com/itavero/homebridge-z2m/issues/208)) ## [1.3.0] - 2021-06-20 ### Added diff --git a/src/colorhelper.ts b/src/colorhelper.ts index 43f99fd1..1fa6cbec 100644 --- a/src/colorhelper.ts +++ b/src/colorhelper.ts @@ -83,4 +83,83 @@ export function convertXyToHueSat(x: number, y: number): [number, number] { const hsv = color_convert.rgb.hsv([r, g, b]); return [hsv[0], hsv[1]]; +} + +export function convertMiredColorTemperatureToHueSat(temperature: number) : [number, number] { + const xy = convertMiredColorTemperatureToXY(temperature); + return convertXyToHueSat(xy[0], xy[1]); +} + +export function convertMiredColorTemperatureToXY(temperature: number) : [number, number] { + // Based on MiredColorTemperatureToXY from: + // https://github.com/dresden-elektronik/deconz-rest-plugin/blob/78939ac4ee4b0646fbf542a0f6e83ee995f1a875/colorspace.cpp + const TEMPERATURE_TO_X_TEMPERATURE_THRESHOLD = 4000; + + const TEMPERATURE_TO_Y_FIRST_TEMPERATURE_THRESHOLD = 2222; + const TEMPERATURE_TO_Y_SECOND_TEMPERATURE_THRESHOLD = 4000; + + const TEMPERATURE_TO_X_FIRST_FACTOR_FIRST_EQUATION = 17440695910400; + const TEMPERATURE_TO_X_SECOND_FACTOR_FIRST_EQUATION = 15358885888; + const TEMPERATURE_TO_X_THIRD_FACTOR_FIRST_EQUATION = 57520658; + const TEMPERATURE_TO_X_FOURTH_FACTOR_FIRST_EQUATION = 11790; + + const TEMPERATURE_TO_X_FIRST_FACTOR_SECOND_EQUATION = 198301902438400; + const TEMPERATURE_TO_X_SECOND_FACTOR_SECOND_EQUATION = 138086835814; + const TEMPERATURE_TO_X_THIRD_FACTOR_SECOND_EQUATION = 14590587; + const TEMPERATURE_TO_X_FOURTH_FACTOR_SECOND_EQUATION = 15754; + + const TEMPERATURE_TO_Y_FIRST_FACTOR_FIRST_EQUATION = 18126; + const TEMPERATURE_TO_Y_SECOND_FACTOR_FIRST_EQUATION = 22087; + const TEMPERATURE_TO_Y_THIRD_FACTOR_FIRST_EQUATION = 35808; + const TEMPERATURE_TO_Y_FOURTH_FACTOR_FIRST_EQUATION = 3312; + + const TEMPERATURE_TO_Y_FIRST_FACTOR_SECOND_EQUATION = 15645; + const TEMPERATURE_TO_Y_SECOND_FACTOR_SECOND_EQUATION = 22514; + const TEMPERATURE_TO_Y_THIRD_FACTOR_SECOND_EQUATION = 34265; + const TEMPERATURE_TO_Y_FOURTH_FACTOR_SECOND_EQUATION = 2744; + + const TEMPERATURE_TO_Y_FIRST_FACTOR_THIRD_EQUATION = 50491; + const TEMPERATURE_TO_Y_SECOND_FACTOR_THIRD_EQUATION = 96229; + const TEMPERATURE_TO_Y_THIRD_FACTOR_THIRD_EQUATION = 61458; + const TEMPERATURE_TO_Y_FOURTH_FACTOR_THIRD_EQUATION = 6062; + + let localX = 0; + let localY = 0; + const temp = 1000000 / temperature; + + if (TEMPERATURE_TO_X_TEMPERATURE_THRESHOLD > temp) { + localX = TEMPERATURE_TO_X_THIRD_FACTOR_FIRST_EQUATION / temp + + TEMPERATURE_TO_X_FOURTH_FACTOR_FIRST_EQUATION - + TEMPERATURE_TO_X_SECOND_FACTOR_FIRST_EQUATION / temp / temp - + TEMPERATURE_TO_X_FIRST_FACTOR_FIRST_EQUATION / temp / temp/ temp; + } else { + localX = TEMPERATURE_TO_X_SECOND_FACTOR_SECOND_EQUATION / temp / temp + + TEMPERATURE_TO_X_THIRD_FACTOR_SECOND_EQUATION / temp + + TEMPERATURE_TO_X_FOURTH_FACTOR_SECOND_EQUATION - + TEMPERATURE_TO_X_FIRST_FACTOR_SECOND_EQUATION / temp / temp / temp; + } + + if (TEMPERATURE_TO_Y_FIRST_TEMPERATURE_THRESHOLD > temp) { + localY = TEMPERATURE_TO_Y_THIRD_FACTOR_FIRST_EQUATION * localX / 65536 - + TEMPERATURE_TO_Y_FIRST_FACTOR_FIRST_EQUATION * localX * localX * localX / 281474976710656 - + TEMPERATURE_TO_Y_SECOND_FACTOR_FIRST_EQUATION * localX * localX / 4294967296 - + TEMPERATURE_TO_Y_FOURTH_FACTOR_FIRST_EQUATION; + } else if (TEMPERATURE_TO_Y_SECOND_TEMPERATURE_THRESHOLD > temp) { + localY = TEMPERATURE_TO_Y_THIRD_FACTOR_SECOND_EQUATION * localX / 65536 - + TEMPERATURE_TO_Y_FIRST_FACTOR_SECOND_EQUATION * localX * localX * localX / 281474976710656 - + TEMPERATURE_TO_Y_SECOND_FACTOR_SECOND_EQUATION * localX * localX / 4294967296 - + TEMPERATURE_TO_Y_FOURTH_FACTOR_SECOND_EQUATION; + } else { + localY = TEMPERATURE_TO_Y_THIRD_FACTOR_THIRD_EQUATION * localX / 65536 + + TEMPERATURE_TO_Y_FIRST_FACTOR_THIRD_EQUATION * localX * localX * localX / 281474976710656 - + TEMPERATURE_TO_Y_SECOND_FACTOR_THIRD_EQUATION * localX * localX / 4294967296 - + TEMPERATURE_TO_Y_FOURTH_FACTOR_THIRD_EQUATION; + } + + localY *= 4; + + localX /= 0xFFFF; + localY /= 0xFFFF; + + return [Math.round(localX * 10000) / 10000, Math.round(localY * 10000) / 10000]; } \ No newline at end of file diff --git a/src/converters/light.ts b/src/converters/light.ts index 43a6158c..ab571cec 100644 --- a/src/converters/light.ts +++ b/src/converters/light.ts @@ -13,7 +13,7 @@ import { CharacteristicMonitor, MappingCharacteristicMonitor, NestedCharacteristicMonitor, NumericCharacteristicMonitor, PassthroughCharacteristicMonitor, } from './monitor'; -import { convertHueSatToXy, convertXyToHueSat } from '../colorhelper'; +import { convertHueSatToXy, convertMiredColorTemperatureToHueSat, convertXyToHueSat } from '../colorhelper'; export class LightCreator implements ServiceCreator { createServicesFromExposes(accessory: BasicAccessory, exposes: ExposesEntry[]): void { @@ -33,6 +33,9 @@ export class LightCreator implements ServiceCreator { } class LightHandler implements ServiceHandler { + public static readonly KEY_COLOR_MODE = 'color_mode'; + public static readonly COLOR_MODE_TEMPERATURE = 'color_temp'; + private monitors: CharacteristicMonitor[] = []; private stateExpose: ExposesEntryWithBinaryProperty; private brightnessExpose: ExposesEntryWithNumericRangeProperty | undefined; @@ -81,6 +84,13 @@ class LightHandler implements ServiceHandler { // Color: Hue/Saturation or X/Y this.tryCreateColor(expose, service, accessory); + + // Both temperature and color? + if (this.colorTempExpose !== undefined && this.colorExpose !== undefined) { + // Add monitor to convert Color Temperature to Hue / Saturation + // based on the 'color_mode' + this.monitors.push(new ColorTemperatureToHueSatMonitor(service, this.colorTempExpose.property)); + } } identifier: string; @@ -106,6 +116,25 @@ class LightHandler implements ServiceHandler { } updateState(state: Record): void { + // Use color_mode to filter out the non-active color information + // to prevent "incorrect" updates (leading to "glitches" in the Home.app) + if (LightHandler.KEY_COLOR_MODE in state) { + if (this.colorTempExpose !== undefined + && this.colorTempExpose.property in state + && state[LightHandler.KEY_COLOR_MODE] !== LightHandler.COLOR_MODE_TEMPERATURE) { + // Color mode is NOT Color Temperature. Remove color temperature information. + delete state[this.colorTempExpose.property]; + } + + if (this.colorExpose !== undefined + && this.colorExpose.property !== undefined + && this.colorExpose.property in state + && state[LightHandler.KEY_COLOR_MODE] === LightHandler.COLOR_MODE_TEMPERATURE) { + // Color mode is Color Temperature. Remove HS/XY color information. + delete state[this.colorExpose.property]; + } + } + this.monitors.forEach(m => m.callback(state)); } @@ -138,6 +167,7 @@ class LightHandler implements ServiceHandler { } if (this.colorComponentAExpose === undefined || this.colorComponentBExpose === undefined) { // Can't create service if not all components are present. + this.colorExpose = undefined; return; } @@ -310,6 +340,24 @@ class LightHandler implements ServiceHandler { } } +class ColorTemperatureToHueSatMonitor implements CharacteristicMonitor { + constructor( + private readonly service: Service, + private readonly key_temp: string, + ) { } + + callback(state: Record): void { + if (this.key_temp in state + && LightHandler.KEY_COLOR_MODE in state + && state[LightHandler.KEY_COLOR_MODE] === LightHandler.COLOR_MODE_TEMPERATURE) { + const temperature = state[this.key_temp] as number; + const hueSat = convertMiredColorTemperatureToHueSat(temperature); + this.service.updateCharacteristic(hap.Characteristic.Hue, hueSat[0]); + this.service.updateCharacteristic(hap.Characteristic.Saturation, hueSat[1]); + } + } +} + class ColorXyCharacteristicMonitor implements CharacteristicMonitor { constructor( private readonly service: Service, diff --git a/test/light.spec.ts b/test/light.spec.ts index 99ff5d61..00e176e9 100644 --- a/test/light.spec.ts +++ b/test/light.spec.ts @@ -234,6 +234,24 @@ describe('Light', () => { harness.checkSingleUpdateState('{"brightness":254}', hap.Service.Lightbulb, hap.Characteristic.Brightness, 100); }); + test('Status update: color_mode = color_temp', () => { + expect(harness).toBeDefined(); + harness.checkSingleUpdateState( + '{"color":{"hue":34,"saturation":77,"x":0.4435,"y":0.4062},"color_mode":"color_temp","color_temp":343,"linkquality":72}', + hap.Service.Lightbulb, hap.Characteristic.ColorTemperature, 343); + }); + + test('Status update: color_mode = xy', () => { + expect(harness).toBeDefined(); + harness.checkUpdateState( + '{"color":{"x":0.44416,"y":0.51657},"color_mode":"xy","color_temp":169,"linkquality":75}', + hap.Service.Lightbulb, + new Map([ + [hap.Characteristic.Hue, 60], + [hap.Characteristic.Saturation, 100], + ])); + }); + test('Status update is handled: Color changed to yellow', () => { expect(harness).toBeDefined(); harness.checkUpdateState(