Skip to content
This repository has been archived by the owner on Nov 7, 2024. It is now read-only.

Commit

Permalink
feat(light): add support for xy color mode (#110) (#137)
Browse files Browse the repository at this point in the history
  • Loading branch information
t0bst4r authored Jun 30, 2024
1 parent 06ad05a commit 15eb821
Show file tree
Hide file tree
Showing 9 changed files with 121 additions and 64 deletions.
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@

---

This **Matterbridge Home Assistant** package provides bindings to
connect [HomeAssistant](https://www.npmjs.com/package/home-assistant-js-websocket)
to [Matterbridge](https://github.com/Luligu/matterbridge/).
This **Matterbridge Home Assistant** package provides bindings to connect [HomeAssistant](https://www.npmjs.com/package/home-assistant-js-websocket) to [Matterbridge](https://github.com/Luligu/matterbridge/).

---

[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/t0bst4r)

---

## Installation

### Manual Setup

- Follow [those instructions](https://github.com/Luligu/matterbridge/?tab=readme-ov-file#installation) to
setup `matterbridge`.
- Follow [those instructions](https://github.com/Luligu/matterbridge/?tab=readme-ov-file#installation) to set up `matterbridge`.
- Install the plugin `npm install -g matterbridge-home-assistant`
- Make sure the plugin is configured properly using environment variables (see [Configuration](#configuration)).
- Activate the plugin `matterbridge -add matterbridge-home-assistant`
Expand Down Expand Up @@ -193,8 +196,5 @@ This code can be used to connect your Matter controller (like Alexa, Apple Home
# Contributors
[![Contributors](https://contrib.rocks/image?repo=t0bst4r/matterbridge-home-assistant)](https://github.com/t0bst4r/matterbridge-home-assistant/graphs/contributors)
---
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/t0bst4r)
[<img src="https://github.com/t0bst4r.png" width="50px" alt="t0bst4r" title="t0bst4r" />](https://github.com/t0bst4r)
[<img src="https://github.com/bassrock.png" width="50px" alt="bassrock" title="bassrock" />](https://github.com/bassrock)
10 changes: 6 additions & 4 deletions src/devices/aspects/boolean-state-aspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ export class BooleanStateAspect extends MatterAspect<Entity> {
) {
super(entity.entity_id);
this.log.setLogName('BooleanStateAspect');

device.createDefaultBooleanStateClusterServer(entity.state !== 'off');
device.createDefaultBooleanStateClusterServer(this.isOn(entity));
}

private get booleanStateCluster() {
Expand All @@ -19,11 +18,14 @@ export class BooleanStateAspect extends MatterAspect<Entity> {

async update(state: Entity): Promise<void> {
const booleanStateClusterServer = this.booleanStateCluster!;
const isOnFn = (entity: Entity) => entity.state !== 'off';
const isOn = isOnFn(state);
const isOn = this.isOn(state);
if (booleanStateClusterServer.getStateValueAttribute() !== isOn) {
this.log.debug(`FROM HA: ${state.entity_id} changed boolean state to ${state.state}`);
booleanStateClusterServer.setStateValueAttribute(isOn);
}
}

private isOn(entity: Entity): boolean {
return entity.state !== 'off';
}
}
29 changes: 5 additions & 24 deletions src/devices/aspects/color-control-aspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import { LightEntityColorMode } from '../light-entity-color-mode.js';
export const colorModes = [
LightEntityColorMode.HS,
LightEntityColorMode.RGB,
LightEntityColorMode.XY,
// TODO: ColorMode.RGBW, not yet supported
// TODO: ColorMode.RGBWW, not yet supported
// TODO: ColorMode.XY, not yet supported
];

export class ColorControlAspect extends MatterAspect<Entity> {
Expand Down Expand Up @@ -112,10 +112,14 @@ export class ColorControlAspect extends MatterAspect<Entity> {

private getHomeAssistantColor(entity: HassEntity): Color | undefined {
const hsColor: [number, number] | undefined = entity.attributes.hs_color;
const xyColor: [number, number] | undefined = entity.attributes.xy_color;
const rgbColor: [number, number, number] | undefined = entity.attributes.rgb_color;
if (this.supportedColorModes.includes(LightEntityColorMode.HS) && hsColor != null) {
const [hue, saturation] = hsColor;
return ColorConverter.fromHomeAssistantHS(hue, saturation);
} else if (this.supportedColorModes.includes(LightEntityColorMode.XY) && xyColor != null) {
const [x, y] = xyColor;
return ColorConverter.fromXY(x, y);
} else if (this.supportedColorModes.includes(LightEntityColorMode.RGB) && rgbColor != null) {
const [r, g, b] = rgbColor;
return ColorConverter.fromRGB(r, g, b);
Expand All @@ -136,16 +140,6 @@ export class ColorControlAspect extends MatterAspect<Entity> {
}

private async setHomeAssistantColor(color: Color): Promise<void> {
if (this.supportedColorModes.includes(LightEntityColorMode.HS)) {
await this.setHomeAssistantHS(color);
} else if (this.supportedColorModes.includes(LightEntityColorMode.RGB)) {
await this.setHomeAssistantRGB(color);
} else {
throw new Error(`Could not find the correct color mode for ${this.entityId}`);
}
}

private async setHomeAssistantHS(color: Color): Promise<void> {
const [hue, saturation] = ColorConverter.toHomeAssistantHS(color);
this.log.debug(`SET HS: ${this.entityId}, (HA) hue: ${hue}, (HA) saturation: ${saturation}`);
await this.homeAssistantClient.callService(
Expand All @@ -157,17 +151,4 @@ export class ColorControlAspect extends MatterAspect<Entity> {
{ entity_id: this.entityId },
);
}

private async setHomeAssistantRGB(color: Color): Promise<void> {
const rgbColor = ColorConverter.toRGB(color);
this.log.debug(`SET RGB: ${this.entityId}, rgb: ${rgbColor.join(', ')}`);
await this.homeAssistantClient.callService(
'light',
'turn_on',
{
rgb_color: rgbColor,
},
{ entity_id: this.entityId },
);
}
}
11 changes: 7 additions & 4 deletions src/devices/aspects/level-control-aspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { MatterbridgeDeviceCommands } from '../../util/matterbrigde-device-comma

export interface LevenControlAspectConfig {
getValue: (entity: Entity) => number | undefined;
isSupported?: (entity: Entity) => boolean;
getMinValue?: (entity: Entity) => number | undefined;
getMaxValue?: (entity: Entity) => number | undefined;
moveToLevel: {
Expand All @@ -27,10 +28,12 @@ export class LevelControlAspect extends MatterAspect<Entity> {
) {
super(entity.entity_id);
this.log.setLogName('LevelControlAspect');
this.log.debug(`Entity ${entity.entity_id} supports level control`);
device.createDefaultLevelControlClusterServer();
device.addCommandHandler('moveToLevel', this.moveToLevel.bind(this));
device.addCommandHandler('moveToLevelWithOnOff', this.moveToLevel.bind(this));
if (!config.isSupported || config.isSupported(entity)) {
this.log.debug(`Entity ${entity.entity_id} supports level control`);
device.createDefaultLevelControlClusterServer();
device.addCommandHandler('moveToLevel', this.moveToLevel.bind(this));
device.addCommandHandler('moveToLevelWithOnOff', this.moveToLevel.bind(this));
}
}

private moveToLevel: MatterbridgeDeviceCommands['moveToLevel'] = async ({ request: { level } }) => {
Expand Down
10 changes: 6 additions & 4 deletions src/devices/aspects/occupancy-sensing-aspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ export class OccupancySensingAspect extends MatterAspect<Entity> {
) {
super(entity.entity_id);
this.log.setLogName('OccupancySensingAspect');

device.createDefaultOccupancySensingClusterServer(entity.state !== 'off');
device.createDefaultOccupancySensingClusterServer(this.isOccupied(entity));
}

private get occupancySensingCluster() {
Expand All @@ -19,11 +18,14 @@ export class OccupancySensingAspect extends MatterAspect<Entity> {

async update(state: Entity): Promise<void> {
const occupancySensingClusterSever = this.occupancySensingCluster!;
const isOccupiedFn = (entity: Entity) => entity.state !== 'off';
const isOccupied = isOccupiedFn(state);
const isOccupied = this.isOccupied(state);
if (occupancySensingClusterSever.getOccupancyAttribute().occupied !== isOccupied) {
this.log.debug(`FROM HA: ${state.entity_id} changed occupancy state to ${state.state}`);
occupancySensingClusterSever.setOccupancyAttribute({ occupied: isOccupied });
}
}

private isOccupied(entity: Entity): boolean {
return entity.state !== 'off';
}
}
31 changes: 13 additions & 18 deletions src/devices/light-device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,18 @@ export class LightDevice extends HomeAssistantDevice {
this.addAspect(new IdentifyAspect(this.matter, entity));
this.addAspect(new OnOffAspect(homeAssistantClient, this.matter, entity));
this.addAspect(new ColorControlAspect(homeAssistantClient, this.matter, entity));

this.configureLevelControl(homeAssistantClient, entity);
}

private configureLevelControl(homeAssistantClient: HomeAssistantClient, entity: Entity) {
const supportedColorModes: LightEntityColorMode[] = entity.attributes.supported_color_modes ?? [];
const supportsLevelControl = supportedColorModes.some((mode) => brightnessModes.includes(mode));
if (supportsLevelControl) {
this.addAspect(
new LevelControlAspect(homeAssistantClient, this.matter, entity, {
getValue: (entity) => entity.attributes.brightness,
moveToLevel: {
service: 'light.turn_on',
data: (brightness) => ({ brightness }),
},
}),
);
}
this.addAspect(
new LevelControlAspect(homeAssistantClient, this.matter, entity, {
isSupported: (entity) => {
const supportedColorModes: LightEntityColorMode[] = entity.attributes.supported_color_modes ?? [];
return supportedColorModes.some((mode) => brightnessModes.includes(mode));
},
getValue: (entity) => entity.attributes.brightness,
moveToLevel: {
service: 'light.turn_on',
data: (brightness) => ({ brightness }),
},
}),
);
}
}
12 changes: 12 additions & 0 deletions src/util/color-converter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Color from 'color';
// @ts-expect-error color-temperature does not have any typings
import { colorTemperature2rgb } from 'color-temperature';
import { xyColorToRgbColor } from './xy-to-rgb.js';

/*
* Matter:
Expand Down Expand Up @@ -42,6 +43,17 @@ export abstract class ColorConverter {
return Color.hsv((hue / 255) * 360, (saturation / 254) * 100, 100);
}

/**
* Create a color object from `x` and `y` values set via Matter
* @param x X, Values between 0 and 1
* @param y Y, Values between 0 and 1
* @return Color
*/
static fromXY(x: number, y: number): Color {
const rgb = xyColorToRgbColor(x, y);
return Color.rgb(...rgb);
}

/**
* Create a color object from `rgb_color` value
* @param r Red, 0-255
Expand Down
51 changes: 51 additions & 0 deletions src/util/xy-to-rgb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// COPIED FROM Luligu/matterbridge/utils/colorUtils, because exported utils do not work

export function xyColorToRgbColor(x: number, y: number, brightness = 254): [number, number, number] {
const z = 1.0 - x - y;
const Y = (brightness / 254).toFixed(2);
const X = (Number(Y) / y) * x;
const Z = (Number(Y) / y) * z;

// Convert to RGB using Wide RGB D65 conversion
let red = X * 1.656492 - Number(Y) * 0.354851 - Z * 0.255038;
let green = -X * 0.707196 + Number(Y) * 1.655397 + Z * 0.036152;
let blue = X * 0.051713 - Number(Y) * 0.121364 + Z * 1.01153;

// If red, green or blue is larger than 1.0 set it back to the maximum of 1.0
if (red > blue && red > green && red > 1.0) {
green = green / red;
blue = blue / red;
red = 1.0;
} else if (green > blue && green > red && green > 1.0) {
red = red / green;
blue = blue / green;
green = 1.0;
} else if (blue > red && blue > green && blue > 1.0) {
red = red / blue;
green = green / blue;
blue = 1.0;
}

// Reverse gamma correction
red = red <= 0.0031308 ? 12.92 * red : (1.0 + 0.055) * Math.pow(red, 1.0 / 2.4) - 0.055;
green = green <= 0.0031308 ? 12.92 * green : (1.0 + 0.055) * Math.pow(green, 1.0 / 2.4) - 0.055;
blue = blue <= 0.0031308 ? 12.92 * blue : (1.0 + 0.055) * Math.pow(blue, 1.0 / 2.4) - 0.055;

// Convert normalized decimal to decimal
red = Math.round(red * 255);
green = Math.round(green * 255);
blue = Math.round(blue * 255);

// Normalize
if (isNaN(red) || red < 0) {
red = 0;
}
if (isNaN(green) || green < 0) {
green = 0;
}
if (isNaN(blue) || blue < 0) {
blue = 0;
}

return [red, green, blue];
}
11 changes: 11 additions & 0 deletions test/util/color-converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ describe('ColorConverter', () => {
expect(actual.map(precision)).toEqual(hs.map(precision));
});

it.each([
{ title: 'red', xy: [0.701, 0.299], hs: [0, 100] },
{ title: 'blue', xy: [0.136, 0.04], hs: [240.94, 100] },
{ title: 'green', xy: [0.172, 0.747], hs: [120.24, 100] },
{ title: 'other', xy: [0.372, 0.239], hs: [307.96, 38.43] },
])('should convert XY to Home Assistant HS - $title', ({ xy: [x, y], hs }) => {
const color = ColorConverter.fromXY(x, y);
const actual = ColorConverter.toHomeAssistantHS(color);
expect(actual.map(precision)).toEqual(hs.map(precision));
});

it.each([
{ title: 'red', rgb: [255, 0, 0], hs: [0, 254] },
{ title: 'blue', rgb: [0, 0, 255], hs: [170, 254] },
Expand Down

0 comments on commit 15eb821

Please sign in to comment.