Add color support to matter lights (#87366)

* Added the correct attributes to ColorTemperatureLight and ExtendedColorLight and added CurrentY to codespell ignore word list

* Added enums for matter color modes

* Added support for reading color and color temperature settings from matter api

* Added away of getting the ColorControl featureMap while the get_cluster(ColorControl) function is fixed

* Initial working implementation of color and color temperature

* Full supports for lights with both hs and xy

* Added checks to make sure color features are supported before making matter call

* Changed how color mode is figured out

* Moved the logic that gets the brightness to its own function

* Adds matter light tests for hass triggered events

* Adds full test coverage for matter all types of matter lights

* Adds full test coverage for matter all types of matter lights

* Moved light convertion logic to util.py

* Reorderd codespell ignore list and removed unused code

* Adds brightness state test
This commit is contained in:
Arturo 2023-02-07 13:44:02 -06:00 committed by GitHub
parent 096f6eb554
commit e84a11963e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 2606 additions and 57 deletions

View file

@ -31,7 +31,7 @@ repos:
hooks:
- id: codespell
args:
- --ignore-words-list=additionals,alle,alot,ba,bre,bund,datas,dof,dur,ether,farenheit,falsy,fo,haa,hass,hist,iam,iff,iif,incomfort,ines,ist,lightsensor,mut,nam,nd,pres,pullrequests,referer,resset,rime,ser,serie,sur,te,technik,ue,uint,unsecure,visability,wan,wanna,withing,zar
- --ignore-words-list=additionals,alle,alot,ba,bre,bund,currenty,datas,dof,dur,ether,farenheit,falsy,fo,haa,hass,hist,iam,iff,iif,incomfort,ines,ist,lightsensor,mut,nam,nd,pres,pullrequests,referer,resset,rime,ser,serie,sur,te,technik,ue,uint,unsecure,visability,wan,wanna,withing,zar
- --skip="./.*,*.csv,*.json"
- --quiet-level=2
exclude_types: [csv, json]

View file

@ -2,6 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from functools import partial
from typing import Any
@ -10,6 +11,9 @@ from matter_server.common.models import device_types
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP,
ATTR_HS_COLOR,
ATTR_XY_COLOR,
ColorMode,
LightEntity,
LightEntityDescription,
@ -19,9 +23,41 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import LOGGER
from .entity import MatterEntity, MatterEntityDescriptionBaseClass
from .helpers import get_matter
from .util import renormalize
from .util import (
convert_to_hass_hs,
convert_to_hass_xy,
convert_to_matter_hs,
convert_to_matter_xy,
renormalize,
)
class MatterColorMode(Enum):
"""Matter color mode."""
HS = 0
XY = 1
COLOR_TEMP = 2
COLOR_MODE_MAP = {
MatterColorMode.HS: ColorMode.HS,
MatterColorMode.XY: ColorMode.XY,
MatterColorMode.COLOR_TEMP: ColorMode.COLOR_TEMP,
}
class MatterColorControlFeatures(Enum):
"""Matter color control features."""
HS = 0 # Hue and saturation (Optional if device is color capable)
EHUE = 1 # Enhanced hue and saturation (Optional if device is color capable)
COLOR_LOOP = 2 # Color loop (Optional if device is color capable)
XY = 3 # XY (Mandatory if device is color capable)
COLOR_TEMP = 4 # Color temperature (Mandatory if device is color capable)
async def async_setup_entry(
@ -39,73 +75,195 @@ class MatterLight(MatterEntity, LightEntity):
entity_description: MatterLightEntityDescription
def _supports_feature(
self, feature_map: int, feature: MatterColorControlFeatures
) -> bool:
"""Return if device supports given feature."""
return (feature_map & (1 << feature.value)) != 0
def _supports_color_mode(self, color_feature: MatterColorControlFeatures) -> bool:
"""Return if device supports given color mode."""
feature_map = self._device_type_instance.node.get_attribute(
self._device_type_instance.endpoint,
clusters.ColorControl,
clusters.ColorControl.Attributes.FeatureMap,
)
assert isinstance(feature_map.value, int)
return self._supports_feature(feature_map.value, color_feature)
def _supports_hs_color(self) -> bool:
"""Return if device supports hs color."""
return self._supports_color_mode(MatterColorControlFeatures.HS)
def _supports_xy_color(self) -> bool:
"""Return if device supports xy color."""
return self._supports_color_mode(MatterColorControlFeatures.XY)
def _supports_color_temperature(self) -> bool:
"""Return if device supports color temperature."""
return self._supports_color_mode(MatterColorControlFeatures.COLOR_TEMP)
def _supports_brightness(self) -> bool:
"""Return if device supports brightness."""
return (
clusters.LevelControl.Attributes.CurrentLevel
in self.entity_description.subscribe_attributes
)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn light on."""
if ATTR_BRIGHTNESS not in kwargs or not self._supports_brightness():
await self.matter_client.send_device_command(
node_id=self._device_type_instance.node.node_id,
endpoint=self._device_type_instance.endpoint,
command=clusters.OnOff.Commands.On(),
)
return
def _supports_color(self) -> bool:
"""Return if device supports color."""
return (
clusters.ColorControl.Attributes.ColorMode
in self.entity_description.subscribe_attributes
)
async def _set_xy_color(self, xy_color: tuple[float, float]) -> None:
"""Set xy color."""
matter_xy = convert_to_matter_xy(xy_color)
LOGGER.debug("Setting xy color to %s", matter_xy)
await self.send_device_command(
clusters.ColorControl.Commands.MoveToColor(
colorX=int(matter_xy[0]),
colorY=int(matter_xy[1]),
# It's required in TLV. We don't implement transition time yet.
transitionTime=0,
)
)
async def _set_hs_color(self, hs_color: tuple[float, float]) -> None:
"""Set hs color."""
matter_hs = convert_to_matter_hs(hs_color)
LOGGER.debug("Setting hs color to %s", matter_hs)
await self.send_device_command(
clusters.ColorControl.Commands.MoveToHueAndSaturation(
hue=int(matter_hs[0]),
saturation=int(matter_hs[1]),
# It's required in TLV. We don't implement transition time yet.
transitionTime=0,
)
)
async def _set_color_temp(self, color_temp: int) -> None:
"""Set color temperature."""
LOGGER.debug("Setting color temperature to %s", color_temp)
await self.send_device_command(
clusters.ColorControl.Commands.MoveToColorTemperature(
colorTemperature=color_temp,
# It's required in TLV. We don't implement transition time yet.
transitionTime=0,
)
)
async def _set_brightness(self, brightness: int) -> None:
"""Set brightness."""
LOGGER.debug("Setting brightness to %s", brightness)
level_control = self._device_type_instance.get_cluster(clusters.LevelControl)
# We check above that the device supports brightness, ie level control.
assert level_control is not None
level = round(
renormalize(
kwargs[ATTR_BRIGHTNESS],
brightness,
(0, 255),
(level_control.minLevel, level_control.maxLevel),
)
)
await self.matter_client.send_device_command(
node_id=self._device_type_instance.node.node_id,
endpoint=self._device_type_instance.endpoint,
command=clusters.LevelControl.Commands.MoveToLevelWithOnOff(
await self.send_device_command(
clusters.LevelControl.Commands.MoveToLevelWithOnOff(
level=level,
# It's required in TLV. We don't implement transition time yet.
transitionTime=0,
),
)
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn light off."""
await self.matter_client.send_device_command(
node_id=self._device_type_instance.node.node_id,
endpoint=self._device_type_instance.endpoint,
command=clusters.OnOff.Commands.Off(),
def _get_xy_color(self) -> tuple[float, float]:
"""Get xy color from matter."""
x_color = self.get_matter_attribute(clusters.ColorControl.Attributes.CurrentX)
y_color = self.get_matter_attribute(clusters.ColorControl.Attributes.CurrentY)
assert x_color is not None
assert y_color is not None
xy_color = convert_to_hass_xy((x_color.value, y_color.value))
LOGGER.debug(
"Got xy color %s for %s",
xy_color,
self._device_type_instance,
)
@callback
def _update_from_device(self) -> None:
"""Update from device."""
supports_brigthness = self._supports_brightness()
return xy_color
if self._attr_supported_color_modes is None and supports_brigthness:
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
def _get_hs_color(self) -> tuple[float, float]:
"""Get hs color from matter."""
if attr := self.get_matter_attribute(clusters.OnOff.Attributes.OnOff):
self._attr_is_on = attr.value
hue = self.get_matter_attribute(clusters.ColorControl.Attributes.CurrentHue)
if supports_brigthness:
level_control = self._device_type_instance.get_cluster(
clusters.LevelControl
saturation = self.get_matter_attribute(
clusters.ColorControl.Attributes.CurrentSaturation
)
# We check above that the device supports brightness, ie level control.
assert hue is not None
assert saturation is not None
hs_color = convert_to_hass_hs((hue.value, saturation.value))
LOGGER.debug(
"Got hs color %s for %s",
hs_color,
self._device_type_instance,
)
return hs_color
def _get_color_temperature(self) -> int:
"""Get color temperature from matter."""
color_temp = self.get_matter_attribute(
clusters.ColorControl.Attributes.ColorTemperatureMireds
)
assert color_temp is not None
LOGGER.debug(
"Got color temperature %s for %s",
color_temp.value,
self._device_type_instance,
)
return int(color_temp.value)
def _get_brightness(self) -> int:
"""Get brightness from matter."""
level_control = self._device_type_instance.get_cluster(clusters.LevelControl)
# We should not get here if brightness is not supported.
assert level_control is not None
# Convert brightness to Home Assistant = 0..255
self._attr_brightness = round(
LOGGER.debug(
"Got brightness %s for %s",
level_control.currentLevel,
self._device_type_instance,
)
return round(
renormalize(
level_control.currentLevel,
(level_control.minLevel, level_control.maxLevel),
@ -113,6 +271,110 @@ class MatterLight(MatterEntity, LightEntity):
)
)
def _get_color_mode(self) -> ColorMode:
"""Get color mode from matter."""
color_mode = self.get_matter_attribute(
clusters.ColorControl.Attributes.ColorMode
)
assert color_mode is not None
ha_color_mode = COLOR_MODE_MAP[MatterColorMode(color_mode.value)]
LOGGER.debug(
"Got color mode (%s) for %s", ha_color_mode, self._device_type_instance
)
return ha_color_mode
async def send_device_command(self, command: Any) -> None:
"""Send device command."""
await self.matter_client.send_device_command(
node_id=self._device_type_instance.node.node_id,
endpoint=self._device_type_instance.endpoint,
command=command,
)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn light on."""
hs_color = kwargs.get(ATTR_HS_COLOR)
xy_color = kwargs.get(ATTR_XY_COLOR)
color_temp = kwargs.get(ATTR_COLOR_TEMP)
brightness = kwargs.get(ATTR_BRIGHTNESS)
if self._supports_color():
if hs_color is not None and self._supports_hs_color():
await self._set_hs_color(hs_color)
elif xy_color is not None and self._supports_xy_color():
await self._set_xy_color(xy_color)
elif color_temp is not None and self._supports_color_temperature():
await self._set_color_temp(color_temp)
if brightness is not None and self._supports_brightness():
await self._set_brightness(brightness)
return
await self.send_device_command(
clusters.OnOff.Commands.On(),
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn light off."""
await self.send_device_command(
clusters.OnOff.Commands.Off(),
)
@callback
def _update_from_device(self) -> None:
"""Update from device."""
supports_color = self._supports_color()
supports_color_temperature = (
self._supports_color_temperature() if supports_color else False
)
supports_brightness = self._supports_brightness()
if self._attr_supported_color_modes is None:
supported_color_modes = set()
if supports_color:
supported_color_modes.add(ColorMode.XY)
if self._supports_hs_color():
supported_color_modes.add(ColorMode.HS)
if supports_color_temperature:
supported_color_modes.add(ColorMode.COLOR_TEMP)
if supports_brightness:
supported_color_modes.add(ColorMode.BRIGHTNESS)
self._attr_supported_color_modes = (
supported_color_modes if supported_color_modes else None
)
LOGGER.debug(
"Supported color modes: %s for %s",
self._attr_supported_color_modes,
self._device_type_instance,
)
if supports_color:
self._attr_color_mode = self._get_color_mode()
if self._attr_color_mode == ColorMode.HS:
self._attr_hs_color = self._get_hs_color()
else:
self._attr_xy_color = self._get_xy_color()
if supports_color_temperature:
self._attr_color_temp = self._get_color_temperature()
if attr := self.get_matter_attribute(clusters.OnOff.Attributes.OnOff):
self._attr_is_on = attr.value
if supports_brightness:
self._attr_brightness = self._get_brightness()
@dataclass
class MatterLightEntityDescription(
@ -159,7 +421,7 @@ DEVICE_ENTITY: dict[
subscribe_attributes=(
clusters.OnOff.Attributes.OnOff,
clusters.LevelControl.Attributes.CurrentLevel,
clusters.ColorControl,
clusters.ColorControl.Attributes.ColorTemperatureMireds,
),
),
device_types.ExtendedColorLight: MatterLightEntityDescriptionFactory(
@ -167,7 +429,12 @@ DEVICE_ENTITY: dict[
subscribe_attributes=(
clusters.OnOff.Attributes.OnOff,
clusters.LevelControl.Attributes.CurrentLevel,
clusters.ColorControl,
clusters.ColorControl.Attributes.ColorMode,
clusters.ColorControl.Attributes.CurrentHue,
clusters.ColorControl.Attributes.CurrentSaturation,
clusters.ColorControl.Attributes.CurrentX,
clusters.ColorControl.Attributes.CurrentY,
clusters.ColorControl.Attributes.ColorTemperatureMireds,
),
),
}

View file

@ -1,6 +1,8 @@
"""Provide integration utilities."""
from __future__ import annotations
XY_COLOR_FACTOR = 65536
def renormalize(
number: float, from_range: tuple[float, float], to_range: tuple[float, float]
@ -9,3 +11,33 @@ def renormalize(
delta1 = from_range[1] - from_range[0]
delta2 = to_range[1] - to_range[0]
return (delta2 * (number - from_range[0]) / delta1) + to_range[0]
def convert_to_matter_hs(hass_hs: tuple[float, float]) -> tuple[float, float]:
"""Convert Home Assistant HS to Matter HS."""
return (
hass_hs[0] / 360 * 254,
renormalize(hass_hs[1], (0, 100), (0, 254)),
)
def convert_to_hass_hs(matter_hs: tuple[float, float]) -> tuple[float, float]:
"""Convert Matter HS to Home Assistant HS."""
return (
matter_hs[0] * 360 / 254,
renormalize(matter_hs[1], (0, 254), (0, 100)),
)
def convert_to_matter_xy(hass_xy: tuple[float, float]) -> tuple[float, float]:
"""Convert Home Assistant XY to Matter XY."""
return (hass_xy[0] * XY_COLOR_FACTOR, hass_xy[1] * XY_COLOR_FACTOR)
def convert_to_hass_xy(matter_xy: tuple[float, float]) -> tuple[float, float]:
"""Convert Matter XY to Home Assistant XY."""
return (matter_xy[0] / XY_COLOR_FACTOR, matter_xy[1] / XY_COLOR_FACTOR)

File diff suppressed because it is too large Load diff

View file

@ -20,7 +20,7 @@ async def light_node_fixture(
) -> MatterNode:
"""Fixture for a light node."""
return await setup_integration_with_node_fixture(
hass, "dimmable-light", matter_client
hass, "extended-color-light", matter_client
)
@ -30,22 +30,13 @@ async def test_turn_on(
light_node: MatterNode,
) -> None:
"""Test turning on a light."""
state = hass.states.get("light.mock_dimmable_light")
assert state
assert state.state == "on"
set_node_attribute(light_node, 1, 6, 0, False)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("light.mock_dimmable_light")
assert state
assert state.state == "off"
# OnOff test
await hass.services.async_call(
"light",
"turn_on",
{
"entity_id": "light.mock_dimmable_light",
"entity_id": "light.mock_extended_color_light",
},
blocking=True,
)
@ -58,11 +49,12 @@ async def test_turn_on(
)
matter_client.send_device_command.reset_mock()
# Brightness test
await hass.services.async_call(
"light",
"turn_on",
{
"entity_id": "light.mock_dimmable_light",
"entity_id": "light.mock_extended_color_light",
"brightness": 128,
},
blocking=True,
@ -77,6 +69,154 @@ async def test_turn_on(
transitionTime=0,
),
)
matter_client.send_device_command.reset_mock()
# HS Color test
await hass.services.async_call(
"light",
"turn_on",
{
"entity_id": "light.mock_extended_color_light",
"hs_color": [0, 0],
},
blocking=True,
)
assert matter_client.send_device_command.call_count == 2
matter_client.send_device_command.assert_has_calls(
[
call(
node_id=light_node.node_id,
endpoint=1,
command=clusters.ColorControl.Commands.MoveToHueAndSaturation(
hue=0,
saturation=0,
transitionTime=0,
),
),
call(
node_id=light_node.node_id,
endpoint=1,
command=clusters.OnOff.Commands.On(),
),
]
)
matter_client.send_device_command.reset_mock()
# XY Color test
await hass.services.async_call(
"light",
"turn_on",
{
"entity_id": "light.mock_extended_color_light",
"xy_color": [0.5, 0.5],
},
blocking=True,
)
assert matter_client.send_device_command.call_count == 2
matter_client.send_device_command.assert_has_calls(
[
call(
node_id=light_node.node_id,
endpoint=1,
command=clusters.ColorControl.Commands.MoveToColor(
colorX=(0.5 * 65536),
colorY=(0.5 * 65536),
transitionTime=0,
),
),
call(
node_id=light_node.node_id,
endpoint=1,
command=clusters.OnOff.Commands.On(),
),
]
)
matter_client.send_device_command.reset_mock()
# Color Temperature test
await hass.services.async_call(
"light",
"turn_on",
{
"entity_id": "light.mock_extended_color_light",
"color_temp": 300,
},
blocking=True,
)
assert matter_client.send_device_command.call_count == 2
matter_client.send_device_command.assert_has_calls(
[
call(
node_id=light_node.node_id,
endpoint=1,
command=clusters.ColorControl.Commands.MoveToColorTemperature(
colorTemperature=300,
transitionTime=0,
),
),
call(
node_id=light_node.node_id,
endpoint=1,
command=clusters.OnOff.Commands.On(),
),
]
)
matter_client.send_device_command.reset_mock()
state = hass.states.get("light.mock_extended_color_light")
assert state
assert state.state == "on"
# HS Color Test
set_node_attribute(light_node, 1, 768, 8, 0)
set_node_attribute(light_node, 1, 768, 1, 50)
set_node_attribute(light_node, 1, 768, 0, 100)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("light.mock_extended_color_light")
assert state
assert state.attributes["color_mode"] == "hs"
assert state.attributes["hs_color"] == (141.732, 19.685)
# XY Color Test
set_node_attribute(light_node, 1, 768, 8, 1)
set_node_attribute(light_node, 1, 768, 3, 50)
set_node_attribute(light_node, 1, 768, 4, 100)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("light.mock_extended_color_light")
assert state
assert state.attributes["color_mode"] == "xy"
assert state.attributes["xy_color"] == (0.0007630, 0.001526)
# Color Temperature Test
set_node_attribute(light_node, 1, 768, 8, 2)
set_node_attribute(light_node, 1, 768, 7, 100)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("light.mock_extended_color_light")
assert state
assert state.attributes["color_mode"] == "color_temp"
assert state.attributes["color_temp"] == 100
# Brightness state test
set_node_attribute(light_node, 1, 8, 0, 50)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("light.mock_extended_color_light")
assert state
assert state.attributes["brightness"] == 49
# Off state test
set_node_attribute(light_node, 1, 6, 0, False)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("light.mock_extended_color_light")
assert state
assert state.state == "off"
async def test_turn_off(
@ -85,7 +225,7 @@ async def test_turn_off(
light_node: MatterNode,
) -> None:
"""Test turning off a light."""
state = hass.states.get("light.mock_dimmable_light")
state = hass.states.get("light.mock_extended_color_light")
assert state
assert state.state == "on"
@ -93,7 +233,7 @@ async def test_turn_off(
"light",
"turn_off",
{
"entity_id": "light.mock_dimmable_light",
"entity_id": "light.mock_extended_color_light",
},
blocking=True,
)