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: hooks:
- id: codespell - id: codespell
args: 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" - --skip="./.*,*.csv,*.json"
- --quiet-level=2 - --quiet-level=2
exclude_types: [csv, json] exclude_types: [csv, json]

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum
from functools import partial from functools import partial
from typing import Any from typing import Any
@ -10,6 +11,9 @@ from matter_server.common.models import device_types
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP,
ATTR_HS_COLOR,
ATTR_XY_COLOR,
ColorMode, ColorMode,
LightEntity, LightEntity,
LightEntityDescription, LightEntityDescription,
@ -19,9 +23,41 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import LOGGER
from .entity import MatterEntity, MatterEntityDescriptionBaseClass from .entity import MatterEntity, MatterEntityDescriptionBaseClass
from .helpers import get_matter 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( async def async_setup_entry(
@ -39,79 +75,305 @@ class MatterLight(MatterEntity, LightEntity):
entity_description: MatterLightEntityDescription 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: def _supports_brightness(self) -> bool:
"""Return if device supports brightness.""" """Return if device supports brightness."""
return ( return (
clusters.LevelControl.Attributes.CurrentLevel clusters.LevelControl.Attributes.CurrentLevel
in self.entity_description.subscribe_attributes in self.entity_description.subscribe_attributes
) )
async def async_turn_on(self, **kwargs: Any) -> None: def _supports_color(self) -> bool:
"""Turn light on.""" """Return if device supports color."""
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
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) 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 assert level_control is not None
level = round( level = round(
renormalize( renormalize(
kwargs[ATTR_BRIGHTNESS], brightness,
(0, 255), (0, 255),
(level_control.minLevel, level_control.maxLevel), (level_control.minLevel, level_control.maxLevel),
) )
) )
await self.matter_client.send_device_command( await self.send_device_command(
node_id=self._device_type_instance.node.node_id, clusters.LevelControl.Commands.MoveToLevelWithOnOff(
endpoint=self._device_type_instance.endpoint,
command=clusters.LevelControl.Commands.MoveToLevelWithOnOff(
level=level, level=level,
# It's required in TLV. We don't implement transition time yet. # It's required in TLV. We don't implement transition time yet.
transitionTime=0, transitionTime=0,
), )
)
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,
)
return xy_color
def _get_hs_color(self) -> tuple[float, float]:
"""Get hs color from matter."""
hue = self.get_matter_attribute(clusters.ColorControl.Attributes.CurrentHue)
saturation = self.get_matter_attribute(
clusters.ColorControl.Attributes.CurrentSaturation
)
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
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),
(0, 255),
)
)
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: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn light off.""" """Turn light off."""
await self.matter_client.send_device_command( await self.send_device_command(
node_id=self._device_type_instance.node.node_id, clusters.OnOff.Commands.Off(),
endpoint=self._device_type_instance.endpoint,
command=clusters.OnOff.Commands.Off(),
) )
@callback @callback
def _update_from_device(self) -> None: def _update_from_device(self) -> None:
"""Update from device.""" """Update from device."""
supports_brigthness = self._supports_brightness()
if self._attr_supported_color_modes is None and supports_brigthness: supports_color = self._supports_color()
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} 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): if attr := self.get_matter_attribute(clusters.OnOff.Attributes.OnOff):
self._attr_is_on = attr.value self._attr_is_on = attr.value
if supports_brigthness: if supports_brightness:
level_control = self._device_type_instance.get_cluster( self._attr_brightness = self._get_brightness()
clusters.LevelControl
)
# We check above that the device supports brightness, ie level control.
assert level_control is not None
# Convert brightness to Home Assistant = 0..255
self._attr_brightness = round(
renormalize(
level_control.currentLevel,
(level_control.minLevel, level_control.maxLevel),
(0, 255),
)
)
@dataclass @dataclass
@ -159,7 +421,7 @@ DEVICE_ENTITY: dict[
subscribe_attributes=( subscribe_attributes=(
clusters.OnOff.Attributes.OnOff, clusters.OnOff.Attributes.OnOff,
clusters.LevelControl.Attributes.CurrentLevel, clusters.LevelControl.Attributes.CurrentLevel,
clusters.ColorControl, clusters.ColorControl.Attributes.ColorTemperatureMireds,
), ),
), ),
device_types.ExtendedColorLight: MatterLightEntityDescriptionFactory( device_types.ExtendedColorLight: MatterLightEntityDescriptionFactory(
@ -167,7 +429,12 @@ DEVICE_ENTITY: dict[
subscribe_attributes=( subscribe_attributes=(
clusters.OnOff.Attributes.OnOff, clusters.OnOff.Attributes.OnOff,
clusters.LevelControl.Attributes.CurrentLevel, 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.""" """Provide integration utilities."""
from __future__ import annotations from __future__ import annotations
XY_COLOR_FACTOR = 65536
def renormalize( def renormalize(
number: float, from_range: tuple[float, float], to_range: tuple[float, float] 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] delta1 = from_range[1] - from_range[0]
delta2 = to_range[1] - to_range[0] delta2 = to_range[1] - to_range[0]
return (delta2 * (number - from_range[0]) / delta1) + 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: ) -> MatterNode:
"""Fixture for a light node.""" """Fixture for a light node."""
return await setup_integration_with_node_fixture( 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, light_node: MatterNode,
) -> None: ) -> None:
"""Test turning on a light.""" """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( await hass.services.async_call(
"light", "light",
"turn_on", "turn_on",
{ {
"entity_id": "light.mock_dimmable_light", "entity_id": "light.mock_extended_color_light",
}, },
blocking=True, blocking=True,
) )
@ -58,11 +49,12 @@ async def test_turn_on(
) )
matter_client.send_device_command.reset_mock() matter_client.send_device_command.reset_mock()
# Brightness test
await hass.services.async_call( await hass.services.async_call(
"light", "light",
"turn_on", "turn_on",
{ {
"entity_id": "light.mock_dimmable_light", "entity_id": "light.mock_extended_color_light",
"brightness": 128, "brightness": 128,
}, },
blocking=True, blocking=True,
@ -77,6 +69,154 @@ async def test_turn_on(
transitionTime=0, 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( async def test_turn_off(
@ -85,7 +225,7 @@ async def test_turn_off(
light_node: MatterNode, light_node: MatterNode,
) -> None: ) -> None:
"""Test turning off a light.""" """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
assert state.state == "on" assert state.state == "on"
@ -93,7 +233,7 @@ async def test_turn_off(
"light", "light",
"turn_off", "turn_off",
{ {
"entity_id": "light.mock_dimmable_light", "entity_id": "light.mock_extended_color_light",
}, },
blocking=True, blocking=True,
) )