Skip to content

Commit

Permalink
Slightly improve UX for lights with temperature and hue/saturation. (…
Browse files Browse the repository at this point in the history
…see #208)
  • Loading branch information
itavero committed Dec 5, 2021
1 parent 5864b97 commit ff839fe
Show file tree
Hide file tree
Showing 4 changed files with 148 additions and 1 deletion.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions src/colorhelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
50 changes: 49 additions & 1 deletion src/converters/light.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -106,6 +116,25 @@ class LightHandler implements ServiceHandler {
}

updateState(state: Record<string, unknown>): 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));
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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<string, unknown>): 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,
Expand Down
18 changes: 18 additions & 0 deletions test/light.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down

0 comments on commit ff839fe

Please sign in to comment.