From 9e78df0cadf3a23c5f1b1b2e458ee8f034d42eec Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 28 Apr 2022 10:29:09 +0200 Subject: [PATCH 1/6] Migrate zha light to color_mode --- homeassistant/components/zha/light.py | 78 ++++++++++++++------------- 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 2ec507109a26dd..ea0629de604de0 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -23,10 +23,9 @@ ATTR_HS_COLOR, ATTR_MAX_MIREDS, ATTR_MIN_MIREDS, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, - LightEntityFeature, + ColorMode, + brightness_supported, + filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -86,13 +85,11 @@ PARALLEL_UPDATES = 0 SIGNAL_LIGHT_GROUP_STATE_CHANGED = "zha_light_group_state_changed" +COLOR_MODES_GROUP_LIGHT = {ColorMode.COLOR_TEMP, ColorMode.HS} SUPPORT_GROUP_LIGHT = ( - SUPPORT_BRIGHTNESS - | SUPPORT_COLOR_TEMP - | LightEntityFeature.EFFECT - | LightEntityFeature.FLASH - | SUPPORT_COLOR - | LightEntityFeature.TRANSITION + light.LightEntityFeature.EFFECT + | light.LightEntityFeature.FLASH + | light.LightEntityFeature.TRANSITION ) @@ -146,6 +143,7 @@ def __init__(self, *args, **kwargs): self._color_channel = None self._identify_channel = None self._default_transition = None + self._color_mode = ColorMode.UNKNOWN # Set by sub classes @property def extra_state_attributes(self) -> dict[str, Any]: @@ -160,6 +158,11 @@ def is_on(self) -> bool: return False return self._state + @property + def color_mode(self): + """Return the color mode of this light.""" + return self._color_mode + @property def brightness(self): """Return the brightness of this light.""" @@ -230,9 +233,9 @@ async def async_turn_on(self, **kwargs): brightness = self._off_brightness t_log = {} - if ( - brightness is not None or transition - ) and self._supported_features & light.SUPPORT_BRIGHTNESS: + if (brightness is not None or transition) and brightness_supported( + self._attr_supported_color_modes + ): if brightness is not None: level = min(254, brightness) else: @@ -257,10 +260,7 @@ async def async_turn_on(self, **kwargs): self.debug("turned on: %s", t_log) return self._state = True - if ( - light.ATTR_COLOR_TEMP in kwargs - and self.supported_features & light.SUPPORT_COLOR_TEMP - ): + if light.ATTR_COLOR_TEMP in kwargs: temperature = kwargs[light.ATTR_COLOR_TEMP] result = await self._color_channel.move_to_color_temp(temperature, duration) t_log["move_to_color_temp"] = result @@ -270,10 +270,7 @@ async def async_turn_on(self, **kwargs): self._color_temp = temperature self._hs_color = None - if ( - light.ATTR_HS_COLOR in kwargs - and self.supported_features & light.SUPPORT_COLOR - ): + if light.ATTR_HS_COLOR in kwargs: hs_color = kwargs[light.ATTR_HS_COLOR] xy_color = color_util.color_hs_to_xy(*hs_color) result = await self._color_channel.move_to_color( @@ -286,10 +283,7 @@ async def async_turn_on(self, **kwargs): self._hs_color = hs_color self._color_temp = None - if ( - effect == light.EFFECT_COLORLOOP - and self.supported_features & light.LightEntityFeature.EFFECT - ): + if effect == light.EFFECT_COLORLOOP: result = await self._color_channel.color_loop_set( UPDATE_COLORLOOP_ACTION | UPDATE_COLORLOOP_DIRECTION @@ -302,9 +296,7 @@ async def async_turn_on(self, **kwargs): t_log["color_loop_set"] = result self._effect = light.EFFECT_COLORLOOP elif ( - self._effect == light.EFFECT_COLORLOOP - and effect != light.EFFECT_COLORLOOP - and self.supported_features & light.LightEntityFeature.EFFECT + self._effect == light.EFFECT_COLORLOOP and effect != light.EFFECT_COLORLOOP ): result = await self._color_channel.color_loop_set( UPDATE_COLORLOOP_ACTION, @@ -316,10 +308,7 @@ async def async_turn_on(self, **kwargs): t_log["color_loop_set"] = result self._effect = None - if ( - flash is not None - and self._supported_features & light.LightEntityFeature.FLASH - ): + if flash is not None: result = await self._identify_channel.trigger_effect( FLASH_EFFECTS[flash], EFFECT_DEFAULT_VARIANT ) @@ -332,7 +321,7 @@ async def async_turn_on(self, **kwargs): async def async_turn_off(self, **kwargs): """Turn the entity off.""" duration = kwargs.get(light.ATTR_TRANSITION) - supports_level = self.supported_features & light.SUPPORT_BRIGHTNESS + supports_level = brightness_supported(self._attr_supported_color_modes) if duration and supports_level: result = await self._level_channel.move_to_level_with_on_off( @@ -356,6 +345,7 @@ async def async_turn_off(self, **kwargs): class Light(BaseLight, ZhaEntity): """Representation of a ZHA or ZLL light.""" + _attr_supported_color_modes: set(ColorMode) _REFRESH_INTERVAL = (45, 75) def __init__(self, unique_id, zha_device: ZhaDeviceType, channels, **kwargs): @@ -372,19 +362,20 @@ def __init__(self, unique_id, zha_device: ZhaDeviceType, channels, **kwargs): self._cancel_refresh_handle = None effect_list = [] + self._attr_supported_color_modes = {ColorMode.ONOFF} if self._level_channel: - self._supported_features |= light.SUPPORT_BRIGHTNESS + self._attr_supported_color_modes += {ColorMode.BRIGHTNESS} self._supported_features |= light.LightEntityFeature.TRANSITION self._brightness = self._level_channel.current_level if self._color_channel: color_capabilities = self._color_channel.color_capabilities if color_capabilities & CAPABILITIES_COLOR_TEMP: - self._supported_features |= light.SUPPORT_COLOR_TEMP + self._attr_supported_color_modes += {ColorMode.COLOR_TEMP} self._color_temp = self._color_channel.color_temperature if color_capabilities & CAPABILITIES_COLOR_XY: - self._supported_features |= light.SUPPORT_COLOR + self._attr_supported_color_modes += {ColorMode.HS} curr_x = self._color_channel.current_x curr_y = self._color_channel.current_y if curr_x is not None and curr_y is not None: @@ -399,6 +390,16 @@ def __init__(self, unique_id, zha_device: ZhaDeviceType, channels, **kwargs): effect_list.append(light.EFFECT_COLORLOOP) if self._color_channel.color_loop_active == 1: self._effect = light.EFFECT_COLORLOOP + self._attr_supported_color_modes = filter_supported_color_modes( + self._attr_supported_color_modes + ) + if len(self._attr_supported_color_modes) == 1: + self._color_mode = next(iter(self._attr_supported_color_modes)) + else: # Light supports color_temp + hs, determine which mode the light is in + if self._color_channel.color_mode == LightColorMode.COLOR_TEMP: + self._color_mode = ColorMode.COLOR_TEMP + else: + self._color_mode = ColorMode.HS if self._identify_channel: self._supported_features |= light.LightEntityFeature.FLASH @@ -455,6 +456,8 @@ def async_restore_last_state(self, last_state): self._brightness = last_state.attributes["brightness"] if "off_brightness" in last_state.attributes: self._off_brightness = last_state.attributes["off_brightness"] + if "color_mode" in last_state.attributes: + self._color_mode = last_state.attributes["color_mode"] if "color_temp" in last_state.attributes: self._color_temp = last_state.attributes["color_temp"] if "hs_color" in last_state.attributes: @@ -494,11 +497,13 @@ async def async_get_state(self): if (color_mode := results.get("color_mode")) is not None: if color_mode == LightColorMode.COLOR_TEMP: + self._color_mode = ColorMode.COLOR_TEMP color_temp = results.get("color_temperature") if color_temp is not None and color_mode: self._color_temp = color_temp self._hs_color = None else: + self._color_mode = ColorMode.HS color_x = results.get("current_x") color_y = results.get("current_y") if color_x is not None and color_y is not None: @@ -573,6 +578,7 @@ def __init__( CONF_DEFAULT_LIGHT_TRANSITION, 0, ) + self._attr_supported_color_modes = COLOR_MODES_GROUP_LIGHT async def async_added_to_hass(self): """Run when about to be added to hass.""" From 7a05ac86de4c43835026cb601663a54bdd7e837c Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 27 May 2022 08:55:49 +0200 Subject: [PATCH 2/6] Fix restoring color mode --- homeassistant/components/zha/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index ea0629de604de0..40a06e5b2d780a 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -457,7 +457,7 @@ def async_restore_last_state(self, last_state): if "off_brightness" in last_state.attributes: self._off_brightness = last_state.attributes["off_brightness"] if "color_mode" in last_state.attributes: - self._color_mode = last_state.attributes["color_mode"] + self._color_mode = ColorMode(last_state.attributes["color_mode"]) if "color_temp" in last_state.attributes: self._color_temp = last_state.attributes["color_temp"] if "hs_color" in last_state.attributes: From abec02de7022d3cd34579412e4bad859ce32479d Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 27 May 2022 09:19:13 +0200 Subject: [PATCH 3/6] Correct set operations --- homeassistant/components/zha/light.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 40a06e5b2d780a..093abc031d77c7 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -364,18 +364,18 @@ def __init__(self, unique_id, zha_device: ZhaDeviceType, channels, **kwargs): self._attr_supported_color_modes = {ColorMode.ONOFF} if self._level_channel: - self._attr_supported_color_modes += {ColorMode.BRIGHTNESS} + self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) self._supported_features |= light.LightEntityFeature.TRANSITION self._brightness = self._level_channel.current_level if self._color_channel: color_capabilities = self._color_channel.color_capabilities if color_capabilities & CAPABILITIES_COLOR_TEMP: - self._attr_supported_color_modes += {ColorMode.COLOR_TEMP} + self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) self._color_temp = self._color_channel.color_temperature if color_capabilities & CAPABILITIES_COLOR_XY: - self._attr_supported_color_modes += {ColorMode.HS} + self._attr_supported_color_modes.add(ColorMode.HS) curr_x = self._color_channel.current_x curr_y = self._color_channel.current_y if curr_x is not None and curr_y is not None: From 214adf821d2c61d0811a6637ad4b071c6ea93811 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 27 May 2022 09:27:33 +0200 Subject: [PATCH 4/6] Derive color mode from group members --- homeassistant/components/zha/light.py | 29 +++++++++++++++++++++++++-- tests/components/zha/test_light.py | 12 ++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 093abc031d77c7..ae599ea3857090 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -8,7 +8,7 @@ import itertools import logging import random -from typing import Any +from typing import Any, cast from zigpy.zcl.clusters.general import Identify, LevelControl, OnOff from zigpy.zcl.clusters.lighting import Color @@ -17,12 +17,14 @@ from homeassistant.components import light from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_EFFECT_LIST, ATTR_HS_COLOR, ATTR_MAX_MIREDS, ATTR_MIN_MIREDS, + ATTR_SUPPORTED_COLOR_MODES, ColorMode, brightness_supported, filter_supported_color_modes, @@ -578,7 +580,7 @@ def __init__( CONF_DEFAULT_LIGHT_TRANSITION, 0, ) - self._attr_supported_color_modes = COLOR_MODES_GROUP_LIGHT + self._color_mode = None async def async_added_to_hass(self): """Run when about to be added to hass.""" @@ -639,6 +641,29 @@ async def async_update(self) -> None: effects_count = Counter(itertools.chain(all_effects)) self._effect = effects_count.most_common(1)[0][0] + self._attr_color_mode = None + all_color_modes = list( + helpers.find_state_attributes(on_states, ATTR_COLOR_MODE) + ) + if all_color_modes: + # Report the most common color mode, select brightness and onoff last + color_mode_count = Counter(itertools.chain(all_color_modes)) + if ColorMode.ONOFF in color_mode_count: + color_mode_count[ColorMode.ONOFF] = -1 + if ColorMode.BRIGHTNESS in color_mode_count: + color_mode_count[ColorMode.BRIGHTNESS] = 0 + self._attr_color_mode = color_mode_count.most_common(1)[0][0] + + self._attr_supported_color_modes = None + all_supported_color_modes = list( + helpers.find_state_attributes(states, ATTR_SUPPORTED_COLOR_MODES) + ) + if all_supported_color_modes: + # Merge all color modes. + self._attr_supported_color_modes = cast( + set[str], set().union(*all_supported_color_modes) + ) + self._supported_features = 0 for support in helpers.find_state_attributes(states, ATTR_SUPPORTED_FEATURES): # Merge supported features by emulating support for every feature diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 4ac777f5d8e145..8cf0e668503c13 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -12,6 +12,7 @@ DOMAIN as LIGHT_DOMAIN, FLASH_LONG, FLASH_SHORT, + ColorMode, ) from homeassistant.components.zha.core.group import GroupMember from homeassistant.components.zha.light import FLASH_EFFECTS @@ -580,7 +581,11 @@ async def test_zha_group_light_entity( await async_wait_for_updates(hass) # test that the lights were created and are off - assert hass.states.get(group_entity_id).state == STATE_OFF + group_state = hass.states.get(group_entity_id) + assert group_state.state == STATE_OFF + assert group_state.attributes["supported_color_modes"] == [ColorMode.HS] + # Light which is off has no color mode + assert "color_mode" not in group_state.attributes # test turning the lights on and off from the HA await async_test_on_off_from_hass(hass, group_cluster_on_off, group_entity_id) @@ -603,6 +608,11 @@ async def test_zha_group_light_entity( await async_test_dimmer_from_light( hass, dev1_cluster_level, group_entity_id, 150, STATE_ON ) + # Check state + group_state = hass.states.get(group_entity_id) + assert group_state.state == STATE_ON + assert group_state.attributes["supported_color_modes"] == [ColorMode.HS] + assert group_state.attributes["color_mode"] == ColorMode.HS # test long flashing the lights from the HA await async_test_flash_from_hass( From eacb4b31f27a59bc86f8b5cefe1fa041db4ccc7b Mon Sep 17 00:00:00 2001 From: David Mulcahey Date: Fri, 27 May 2022 09:13:36 -0400 Subject: [PATCH 5/6] Add color mode to color channel --- homeassistant/components/zha/core/channels/lighting.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py index 1dbf1d201c870e..13d5b4c27424ce 100644 --- a/homeassistant/components/zha/core/channels/lighting.py +++ b/homeassistant/components/zha/core/channels/lighting.py @@ -36,6 +36,7 @@ class ColorChannel(ZigbeeChannel): MAX_MIREDS: int = 500 MIN_MIREDS: int = 153 ZCL_INIT_ATTRS = { + "color_mode": False, "color_temp_physical_min": True, "color_temp_physical_max": True, "color_capabilities": True, @@ -51,6 +52,11 @@ def color_capabilities(self) -> int: return self.CAPABILITIES_COLOR_XY | self.CAPABILITIES_COLOR_TEMP return self.CAPABILITIES_COLOR_XY + @property + def color_mode(self) -> int | None: + """Return cached value of the color_mode attribute.""" + return self.cluster.get("color_mode") + @property def color_loop_active(self) -> int | None: """Return cached value of the color_loop_active attribute.""" From e2f45562f9c4333aa4ffc59edda04db6c98b7555 Mon Sep 17 00:00:00 2001 From: David Mulcahey Date: Fri, 27 May 2022 09:16:10 -0400 Subject: [PATCH 6/6] use Zigpy color mode enum --- homeassistant/components/zha/light.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index ae599ea3857090..30ae968872997f 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -3,7 +3,6 @@ from collections import Counter from datetime import timedelta -import enum import functools import itertools import logging @@ -95,14 +94,6 @@ ) -class LightColorMode(enum.IntEnum): - """ZCL light color mode enum.""" - - HS_COLOR = 0x00 - XY_COLOR = 0x01 - COLOR_TEMP = 0x02 - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -398,7 +389,7 @@ def __init__(self, unique_id, zha_device: ZhaDeviceType, channels, **kwargs): if len(self._attr_supported_color_modes) == 1: self._color_mode = next(iter(self._attr_supported_color_modes)) else: # Light supports color_temp + hs, determine which mode the light is in - if self._color_channel.color_mode == LightColorMode.COLOR_TEMP: + if self._color_channel.color_mode == Color.ColorMode.Color_temperature: self._color_mode = ColorMode.COLOR_TEMP else: self._color_mode = ColorMode.HS @@ -498,7 +489,7 @@ async def async_get_state(self): ) if (color_mode := results.get("color_mode")) is not None: - if color_mode == LightColorMode.COLOR_TEMP: + if color_mode == Color.ColorMode.Color_temperature: self._color_mode = ColorMode.COLOR_TEMP color_temp = results.get("color_temperature") if color_temp is not None and color_mode: