From 5ca82b2d33b24eaf23b3292cae58ad6e0d704617 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 May 2022 15:38:22 +0200 Subject: [PATCH] Migrate zha light to color_mode (#70970) * Migrate zha light to color_mode * Fix restoring color mode * Correct set operations * Derive color mode from group members * Add color mode to color channel * use Zigpy color mode enum Co-authored-by: David Mulcahey --- .../components/zha/core/channels/lighting.py | 6 + homeassistant/components/zha/light.py | 116 +++++++++++------- tests/components/zha/test_light.py | 12 +- 3 files changed, 86 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py index 1dbf1d201c8..13d5b4c2742 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 @@ class ColorChannel(ZigbeeChannel): 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.""" diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 2ec507109a2..30ae9688729 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -3,12 +3,11 @@ from __future__ import annotations from collections import Counter from datetime import timedelta -import enum import functools 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,16 +16,17 @@ from zigpy.zcl.foundation import Status 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, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, - LightEntityFeature, + ATTR_SUPPORTED_COLOR_MODES, + ColorMode, + brightness_supported, + filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -86,24 +86,14 @@ GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.LIGHT) 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 ) -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, @@ -146,6 +136,7 @@ class BaseLight(LogMixin, light.LightEntity): 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 +151,11 @@ class BaseLight(LogMixin, light.LightEntity): 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 +226,9 @@ class BaseLight(LogMixin, light.LightEntity): 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 +253,7 @@ class BaseLight(LogMixin, light.LightEntity): 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 +263,7 @@ class BaseLight(LogMixin, light.LightEntity): 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 +276,7 @@ class BaseLight(LogMixin, light.LightEntity): 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 +289,7 @@ class BaseLight(LogMixin, light.LightEntity): 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 +301,7 @@ class BaseLight(LogMixin, light.LightEntity): 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 +314,7 @@ class BaseLight(LogMixin, light.LightEntity): 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 +338,7 @@ class BaseLight(LogMixin, light.LightEntity): 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 +355,20 @@ class Light(BaseLight, ZhaEntity): 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.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._supported_features |= light.SUPPORT_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._supported_features |= light.SUPPORT_COLOR + 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: @@ -399,6 +383,16 @@ class Light(BaseLight, ZhaEntity): 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 == Color.ColorMode.Color_temperature: + self._color_mode = ColorMode.COLOR_TEMP + else: + self._color_mode = ColorMode.HS if self._identify_channel: self._supported_features |= light.LightEntityFeature.FLASH @@ -455,6 +449,8 @@ class Light(BaseLight, ZhaEntity): 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 = 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: @@ -493,12 +489,14 @@ class Light(BaseLight, ZhaEntity): ) 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: 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 +571,7 @@ class LightGroup(BaseLight, ZhaGroupEntity): CONF_DEFAULT_LIGHT_TRANSITION, 0, ) + self._color_mode = None async def async_added_to_hass(self): """Run when about to be added to hass.""" @@ -633,6 +632,29 @@ class LightGroup(BaseLight, ZhaGroupEntity): 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 4ac777f5d8e..8cf0e668503 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -12,6 +12,7 @@ from homeassistant.components.light import ( 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(