From 04c6b9c51963418ffebddc7753939700fbea7e42 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Thu, 21 Jul 2022 17:54:50 -0400 Subject: [PATCH] ZHA light entity cleanup (#75573) * use base class attributes * initial hue and saturation support * spec is 65536 not 65535 * fixes * enhanced current hue * fix comparison * clean up * fix channel test * oops * report enhanced current hue --- .../components/zha/core/channels/lighting.py | 71 ++++ homeassistant/components/zha/light.py | 344 ++++++++++-------- tests/components/zha/test_channels.py | 13 +- tests/components/zha/test_light.py | 23 +- 4 files changed, 285 insertions(+), 166 deletions(-) diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py index 99e6101b0bd..36bb0beb17d 100644 --- a/homeassistant/components/zha/core/channels/lighting.py +++ b/homeassistant/components/zha/core/channels/lighting.py @@ -31,6 +31,9 @@ class ColorChannel(ZigbeeChannel): REPORT_CONFIG = ( AttrReportConfig(attr="current_x", config=REPORT_CONFIG_DEFAULT), AttrReportConfig(attr="current_y", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="current_hue", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="enhanced_current_hue", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="current_saturation", config=REPORT_CONFIG_DEFAULT), AttrReportConfig(attr="color_temperature", config=REPORT_CONFIG_DEFAULT), ) MAX_MIREDS: int = 500 @@ -52,6 +55,14 @@ class ColorChannel(ZigbeeChannel): return self.CAPABILITIES_COLOR_XY | self.CAPABILITIES_COLOR_TEMP return self.CAPABILITIES_COLOR_XY + @property + def zcl_color_capabilities(self) -> lighting.Color.ColorCapabilities: + """Return ZCL color capabilities of the light.""" + color_capabilities = self.cluster.get("color_capabilities") + if color_capabilities is None: + return lighting.Color.ColorCapabilities(self.CAPABILITIES_COLOR_XY) + return lighting.Color.ColorCapabilities(color_capabilities) + @property def color_mode(self) -> int | None: """Return cached value of the color_mode attribute.""" @@ -77,6 +88,21 @@ class ColorChannel(ZigbeeChannel): """Return cached value of the current_y attribute.""" return self.cluster.get("current_y") + @property + def current_hue(self) -> int | None: + """Return cached value of the current_hue attribute.""" + return self.cluster.get("current_hue") + + @property + def enhanced_current_hue(self) -> int | None: + """Return cached value of the enhanced_current_hue attribute.""" + return self.cluster.get("enhanced_current_hue") + + @property + def current_saturation(self) -> int | None: + """Return cached value of the current_saturation attribute.""" + return self.cluster.get("current_saturation") + @property def min_mireds(self) -> int: """Return the coldest color_temp that this channel supports.""" @@ -86,3 +112,48 @@ class ColorChannel(ZigbeeChannel): def max_mireds(self) -> int: """Return the warmest color_temp that this channel supports.""" return self.cluster.get("color_temp_physical_max", self.MAX_MIREDS) + + @property + def hs_supported(self) -> bool: + """Return True if the channel supports hue and saturation.""" + return ( + self.zcl_color_capabilities is not None + and lighting.Color.ColorCapabilities.Hue_and_saturation + in self.zcl_color_capabilities + ) + + @property + def enhanced_hue_supported(self) -> bool: + """Return True if the channel supports enhanced hue and saturation.""" + return ( + self.zcl_color_capabilities is not None + and lighting.Color.ColorCapabilities.Enhanced_hue + in self.zcl_color_capabilities + ) + + @property + def xy_supported(self) -> bool: + """Return True if the channel supports xy.""" + return ( + self.zcl_color_capabilities is not None + and lighting.Color.ColorCapabilities.XY_attributes + in self.zcl_color_capabilities + ) + + @property + def color_temp_supported(self) -> bool: + """Return True if the channel supports color temperature.""" + return ( + self.zcl_color_capabilities is not None + and lighting.Color.ColorCapabilities.Color_temperature + in self.zcl_color_capabilities + ) + + @property + def color_loop_supported(self) -> bool: + """Return True if the channel supports color loop.""" + return ( + self.zcl_color_capabilities is not None + and lighting.Color.ColorCapabilities.Color_loop + in self.zcl_color_capabilities + ) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index cc8dc475c43..8aab3d20a5c 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -15,15 +15,6 @@ 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, - ATTR_SUPPORTED_COLOR_MODES, ColorMode, brightness_supported, filter_supported_color_modes, @@ -43,7 +34,6 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.color as color_util from .core import discovery, helpers from .core.const import ( @@ -69,10 +59,6 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -CAPABILITIES_COLOR_LOOP = 0x4 -CAPABILITIES_COLOR_XY = 0x08 -CAPABILITIES_COLOR_TEMP = 0x10 - DEFAULT_MIN_BRIGHTNESS = 2 UPDATE_COLORLOOP_ACTION = 0x1 @@ -88,7 +74,7 @@ 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} +COLOR_MODES_GROUP_LIGHT = {ColorMode.COLOR_TEMP, ColorMode.XY} SUPPORT_GROUP_LIGHT = ( light.LightEntityFeature.EFFECT | light.LightEntityFeature.FLASH @@ -123,24 +109,18 @@ class BaseLight(LogMixin, light.LightEntity): def __init__(self, *args, **kwargs): """Initialize the light.""" super().__init__(*args, **kwargs) - self._available: bool = False - self._brightness: int | None = None + self._attr_min_mireds: int | None = 153 + self._attr_max_mireds: int | None = 500 + self._attr_color_mode = ColorMode.UNKNOWN # Set by sub classes + self._attr_supported_features: int = 0 + self._attr_state: bool | None self._off_with_transition: bool = False self._off_brightness: int | None = None - self._hs_color: tuple[float, float] | None = None - self._color_temp: int | None = None - self._min_mireds: int | None = 153 - self._max_mireds: int | None = 500 - self._effect_list: list[str] | None = None - self._effect: str | None = None - self._supported_features: int = 0 - self._state: bool = False + self._zha_config_transition = self._DEFAULT_MIN_TRANSITION_TIME self._on_off_channel = None self._level_channel = None self._color_channel = None self._identify_channel = None - self._zha_config_transition = self._DEFAULT_MIN_TRANSITION_TIME - self._attr_color_mode = ColorMode.UNKNOWN # Set by sub classes @property def extra_state_attributes(self) -> dict[str, Any]: @@ -154,24 +134,9 @@ class BaseLight(LogMixin, light.LightEntity): @property def is_on(self) -> bool: """Return true if entity is on.""" - if self._state is None: + if self._attr_state is None: return False - return self._state - - @property - def brightness(self): - """Return the brightness of this light.""" - return self._brightness - - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return self._min_mireds - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return self._max_mireds + return self._attr_state @callback def set_level(self, value): @@ -182,34 +147,9 @@ class BaseLight(LogMixin, light.LightEntity): level """ value = max(0, min(254, value)) - self._brightness = value + self._attr_brightness = value self.async_write_ha_state() - @property - def hs_color(self): - """Return the hs color value [int, int].""" - return self._hs_color - - @property - def color_temp(self): - """Return the CT color value in mireds.""" - return self._color_temp - - @property - def effect_list(self): - """Return the list of supported effects.""" - return self._effect_list - - @property - def effect(self): - """Return the current effect.""" - return self._effect - - @property - def supported_features(self): - """Flag supported features.""" - return self._supported_features - async def async_turn_on(self, **kwargs): """Turn the entity on.""" transition = kwargs.get(light.ATTR_TRANSITION) @@ -222,6 +162,7 @@ class BaseLight(LogMixin, light.LightEntity): effect = kwargs.get(light.ATTR_EFFECT) flash = kwargs.get(light.ATTR_FLASH) temperature = kwargs.get(light.ATTR_COLOR_TEMP) + xy_color = kwargs.get(light.ATTR_XY_COLOR) hs_color = kwargs.get(light.ATTR_HS_COLOR) # If the light is currently off but a turn_on call with a color/temperature is sent, @@ -235,19 +176,26 @@ class BaseLight(LogMixin, light.LightEntity): new_color_provided_while_off = ( not isinstance(self, LightGroup) and not self._FORCE_ON - and not self._state + and not self._attr_state and ( ( temperature is not None and ( - self._color_temp != temperature + self._attr_color_temp != temperature or self._attr_color_mode != ColorMode.COLOR_TEMP ) ) + or ( + xy_color is not None + and ( + self._attr_xy_color != xy_color + or self._attr_color_mode != ColorMode.XY + ) + ) or ( hs_color is not None and ( - self.hs_color != hs_color + self._attr_hs_color != hs_color or self._attr_color_mode != ColorMode.HS ) ) @@ -265,7 +213,7 @@ class BaseLight(LogMixin, light.LightEntity): if brightness is not None: level = min(254, brightness) else: - level = self._brightness or 254 + level = self._attr_brightness or 254 t_log = {} @@ -280,7 +228,7 @@ class BaseLight(LogMixin, light.LightEntity): self.debug("turned on: %s", t_log) return # Currently only setting it to "on", as the correct level state will be set at the second move_to_level call - self._state = True + self._attr_state = True if ( (brightness is not None or transition) @@ -294,9 +242,9 @@ class BaseLight(LogMixin, light.LightEntity): if isinstance(result, Exception) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) return - self._state = bool(level) + self._attr_state = bool(level) if level: - self._brightness = level + self._attr_brightness = level if ( brightness is None @@ -310,7 +258,7 @@ class BaseLight(LogMixin, light.LightEntity): if isinstance(result, Exception) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) return - self._state = True + self._attr_state = True if temperature is not None: result = await self._color_channel.move_to_color_temp( @@ -324,11 +272,39 @@ class BaseLight(LogMixin, light.LightEntity): self.debug("turned on: %s", t_log) return self._attr_color_mode = ColorMode.COLOR_TEMP - self._color_temp = temperature - self._hs_color = None + self._attr_color_temp = temperature + self._attr_xy_color = None + self._attr_hs_color = None if hs_color is not None: - xy_color = color_util.color_hs_to_xy(*hs_color) + if self._color_channel.enhanced_hue_supported: + result = await self._color_channel.enhanced_move_to_hue_and_saturation( + int(hs_color[0] * 65535 / 360), + int(hs_color[1] * 2.54), + self._DEFAULT_MIN_TRANSITION_TIME + if new_color_provided_while_off + else duration, + ) + t_log["enhanced_move_to_hue_and_saturation"] = result + else: + result = await self._color_channel.move_to_hue_and_saturation( + int(hs_color[0] * 254 / 360), + int(hs_color[1] * 2.54), + self._DEFAULT_MIN_TRANSITION_TIME + if new_color_provided_while_off + else duration, + ) + t_log["move_to_hue_and_saturation"] = result + if isinstance(result, Exception) or result[1] is not Status.SUCCESS: + self.debug("turned on: %s", t_log) + return + self._attr_color_mode = ColorMode.HS + self._attr_hs_color = hs_color + self._attr_xy_color = None + self._attr_color_temp = None + xy_color = None # don't set xy_color if it is also present + + if xy_color is not None: result = await self._color_channel.move_to_color( int(xy_color[0] * 65535), int(xy_color[1] * 65535), @@ -340,9 +316,10 @@ class BaseLight(LogMixin, light.LightEntity): if isinstance(result, Exception) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) return - self._attr_color_mode = ColorMode.HS - self._hs_color = hs_color - self._color_temp = None + self._attr_color_mode = ColorMode.XY + self._attr_xy_color = xy_color + self._attr_color_temp = None + self._attr_hs_color = None if new_color_provided_while_off: # The light is has the correct color, so we can now transition it to the correct brightness level. @@ -351,9 +328,9 @@ class BaseLight(LogMixin, light.LightEntity): if isinstance(result, Exception) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) return - self._state = bool(level) + self._attr_state = bool(level) if level: - self._brightness = level + self._attr_brightness = level if effect == light.EFFECT_COLORLOOP: result = await self._color_channel.color_loop_set( @@ -366,9 +343,10 @@ class BaseLight(LogMixin, light.LightEntity): 0, # no hue ) t_log["color_loop_set"] = result - self._effect = light.EFFECT_COLORLOOP + self._attr_effect = light.EFFECT_COLORLOOP elif ( - self._effect == light.EFFECT_COLORLOOP and effect != light.EFFECT_COLORLOOP + self._attr_effect == light.EFFECT_COLORLOOP + and effect != light.EFFECT_COLORLOOP ): result = await self._color_channel.color_loop_set( UPDATE_COLORLOOP_ACTION, @@ -378,7 +356,7 @@ class BaseLight(LogMixin, light.LightEntity): 0x0, # update action only, action off, no dir, time, hue ) t_log["color_loop_set"] = result - self._effect = None + self._attr_effect = None if flash is not None: result = await self._identify_channel.trigger_effect( @@ -406,12 +384,12 @@ class BaseLight(LogMixin, light.LightEntity): self.debug("turned off: %s", result) if isinstance(result, Exception) or result[1] is not Status.SUCCESS: return - self._state = False + self._attr_state = False if supports_level: # store current brightness so that the next turn_on uses it. self._off_with_transition = transition is not None - self._off_brightness = self._brightness + self._off_brightness = self._attr_brightness self.async_write_ha_state() @@ -427,44 +405,59 @@ class Light(BaseLight, ZhaEntity): """Initialize the ZHA light.""" super().__init__(unique_id, zha_device, channels, **kwargs) self._on_off_channel = self.cluster_channels[CHANNEL_ON_OFF] - self._state = bool(self._on_off_channel.on_off) + self._attr_state = bool(self._on_off_channel.on_off) self._level_channel = self.cluster_channels.get(CHANNEL_LEVEL) self._color_channel = self.cluster_channels.get(CHANNEL_COLOR) self._identify_channel = self.zha_device.channels.identify_ch if self._color_channel: - self._min_mireds: int | None = self._color_channel.min_mireds - self._max_mireds: int | None = self._color_channel.max_mireds + self._attr_min_mireds: int = self._color_channel.min_mireds + self._attr_max_mireds: int = self._color_channel.max_mireds self._cancel_refresh_handle = None effect_list = [] self._attr_supported_color_modes = {ColorMode.ONOFF} if self._level_channel: self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) - self._supported_features |= light.LightEntityFeature.TRANSITION - self._brightness = self._level_channel.current_level + self._attr_supported_features |= light.LightEntityFeature.TRANSITION + self._attr_brightness = self._level_channel.current_level if self._color_channel: - color_capabilities = self._color_channel.color_capabilities - if color_capabilities & CAPABILITIES_COLOR_TEMP: + if self._color_channel.color_temp_supported: self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) - self._color_temp = self._color_channel.color_temperature + self._attr_color_temp = self._color_channel.color_temperature - if color_capabilities & CAPABILITIES_COLOR_XY: - self._attr_supported_color_modes.add(ColorMode.HS) + if ( + self._color_channel.xy_supported + and not self._color_channel.hs_supported + ): + self._attr_supported_color_modes.add(ColorMode.XY) 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: - self._hs_color = color_util.color_xy_to_hs( - float(curr_x / 65535), float(curr_y / 65535) + self._attr_xy_color = (curr_x / 65535, curr_y / 65535) + else: + self._attr_xy_color = (0, 0) + + if self._color_channel.hs_supported: + self._attr_supported_color_modes.add(ColorMode.HS) + if self._color_channel.enhanced_hue_supported: + curr_hue = self._color_channel.enhanced_current_hue * 65535 / 360 + else: + curr_hue = self._color_channel.current_hue * 254 / 360 + curr_saturation = self._color_channel.current_saturation + if curr_hue is not None and curr_saturation is not None: + self._attr_hs_color = ( + int(curr_hue), + int(curr_saturation * 2.54), ) else: - self._hs_color = (0, 0) + self._attr_hs_color = (0, 0) - if color_capabilities & CAPABILITIES_COLOR_LOOP: - self._supported_features |= light.LightEntityFeature.EFFECT + if self._color_channel.color_loop_supported: + self._attr_supported_features |= light.LightEntityFeature.EFFECT effect_list.append(light.EFFECT_COLORLOOP) if self._color_channel.color_loop_active == 1: - self._effect = light.EFFECT_COLORLOOP + self._attr_effect = light.EFFECT_COLORLOOP self._attr_supported_color_modes = filter_supported_color_modes( self._attr_supported_color_modes ) @@ -475,13 +468,13 @@ class Light(BaseLight, ZhaEntity): if self._color_channel.color_mode == Color.ColorMode.Color_temperature: self._attr_color_mode = ColorMode.COLOR_TEMP else: - self._attr_color_mode = ColorMode.HS + self._attr_color_mode = ColorMode.XY if self._identify_channel: - self._supported_features |= light.LightEntityFeature.FLASH + self._attr_supported_features |= light.LightEntityFeature.FLASH if effect_list: - self._effect_list = effect_list + self._attr_effect_list = effect_list self._zha_config_transition = async_get_zha_config_value( zha_device.gateway.config_entry, @@ -493,7 +486,7 @@ class Light(BaseLight, ZhaEntity): @callback def async_set_state(self, attr_id, attr_name, value): """Set the state.""" - self._state = bool(value) + self._attr_state = bool(value) if value: self._off_with_transition = False self._off_brightness = None @@ -529,9 +522,9 @@ class Light(BaseLight, ZhaEntity): @callback def async_restore_last_state(self, last_state): """Restore previous state.""" - self._state = last_state.state == STATE_ON + self._attr_state = last_state.state == STATE_ON if "brightness" in last_state.attributes: - self._brightness = last_state.attributes["brightness"] + self._attr_brightness = last_state.attributes["brightness"] if "off_with_transition" in last_state.attributes: self._off_with_transition = last_state.attributes["off_with_transition"] if "off_brightness" in last_state.attributes: @@ -539,15 +532,17 @@ class Light(BaseLight, ZhaEntity): if "color_mode" in last_state.attributes: self._attr_color_mode = ColorMode(last_state.attributes["color_mode"]) if "color_temp" in last_state.attributes: - self._color_temp = last_state.attributes["color_temp"] + self._attr_color_temp = last_state.attributes["color_temp"] + if "xy_color" in last_state.attributes: + self._attr_xy_color = last_state.attributes["xy_color"] if "hs_color" in last_state.attributes: - self._hs_color = last_state.attributes["hs_color"] + self._attr_hs_color = last_state.attributes["hs_color"] if "effect" in last_state.attributes: - self._effect = last_state.attributes["effect"] + self._attr_effect = last_state.attributes["effect"] async def async_get_state(self): """Attempt to retrieve the state from the light.""" - if not self.available: + if not self._attr_available: return self.debug("polling current state") if self._on_off_channel: @@ -555,21 +550,32 @@ class Light(BaseLight, ZhaEntity): "on_off", from_cache=False ) if state is not None: - self._state = state + self._attr_state = state if self._level_channel: level = await self._level_channel.get_attribute_value( "current_level", from_cache=False ) if level is not None: - self._brightness = level + self._attr_brightness = level if self._color_channel: attributes = [ "color_mode", - "color_temperature", "current_x", "current_y", - "color_loop_active", ] + if self._color_channel.enhanced_hue_supported: + attributes.append("enhanced_current_hue") + attributes.append("current_saturation") + if ( + self._color_channel.hs_supported + and not self._color_channel.enhanced_hue_supported + ): + attributes.append("current_hue") + attributes.append("current_saturation") + if self._color_channel.color_temp_supported: + attributes.append("color_temperature") + if self._color_channel.color_loop_supported: + attributes.append("color_loop_active") results = await self._color_channel.get_attributes( attributes, from_cache=False, only_cache=False @@ -580,24 +586,40 @@ class Light(BaseLight, ZhaEntity): self._attr_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._attr_color_temp = color_temp + self._attr_xy_color = None + self._attr_hs_color = None + elif color_mode == Color.ColorMode.Hue_and_saturation: self._attr_color_mode = ColorMode.HS + if self._color_channel.enhanced_hue_supported: + current_hue = results.get("enhanced_current_hue") + else: + current_hue = results.get("current_hue") + current_saturation = results.get("current_saturation") + if current_hue is not None and current_saturation is not None: + self._attr_hs_color = ( + int(current_hue * 360 / 65535) + if self._color_channel.enhanced_hue_supported + else int(current_hue * 360 / 254), + int(current_saturation / 254), + ) + self._attr_xy_color = None + self._attr_color_temp = None + else: + self._attr_color_mode = Color.ColorMode.X_and_Y color_x = results.get("current_x") color_y = results.get("current_y") if color_x is not None and color_y is not None: - self._hs_color = color_util.color_xy_to_hs( - float(color_x / 65535), float(color_y / 65535) - ) - self._color_temp = None + self._attr_xy_color = (color_x / 65535, color_y / 65535) + self._attr_color_temp = None + self._attr_hs_color = None color_loop_active = results.get("color_loop_active") if color_loop_active is not None: if color_loop_active == 1: - self._effect = light.EFFECT_COLORLOOP + self._attr_effect = light.EFFECT_COLORLOOP else: - self._effect = None + self._attr_effect = None async def async_update(self): """Update to the latest state.""" @@ -671,6 +693,12 @@ class LightGroup(BaseLight, ZhaGroupEntity): ) self._attr_color_mode = None + # remove this when all ZHA platforms and base entities are updated + @property + def available(self) -> bool: + """Return entity availability.""" + return self._attr_available + async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() @@ -700,39 +728,49 @@ class LightGroup(BaseLight, ZhaGroupEntity): states: list[State] = list(filter(None, all_states)) on_states = [state for state in states if state.state == STATE_ON] - self._state = len(on_states) > 0 - self._available = any(state.state != STATE_UNAVAILABLE for state in states) + self._attr_state = len(on_states) > 0 + self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) - self._brightness = helpers.reduce_attribute(on_states, ATTR_BRIGHTNESS) - - self._hs_color = helpers.reduce_attribute( - on_states, ATTR_HS_COLOR, reduce=helpers.mean_tuple + self._attr_brightness = helpers.reduce_attribute( + on_states, light.ATTR_BRIGHTNESS ) - self._color_temp = helpers.reduce_attribute(on_states, ATTR_COLOR_TEMP) - self._min_mireds = helpers.reduce_attribute( - states, ATTR_MIN_MIREDS, default=153, reduce=min - ) - self._max_mireds = helpers.reduce_attribute( - states, ATTR_MAX_MIREDS, default=500, reduce=max + self._attr_xy_color = helpers.reduce_attribute( + on_states, light.ATTR_XY_COLOR, reduce=helpers.mean_tuple ) - self._effect_list = None - all_effect_lists = list(helpers.find_state_attributes(states, ATTR_EFFECT_LIST)) + self._attr_hs_color = helpers.reduce_attribute( + on_states, light.ATTR_HS_COLOR, reduce=helpers.mean_tuple + ) + + self._attr_color_temp = helpers.reduce_attribute( + on_states, light.ATTR_COLOR_TEMP + ) + self._attr_min_mireds = helpers.reduce_attribute( + states, light.ATTR_MIN_MIREDS, default=153, reduce=min + ) + self._attr_max_mireds = helpers.reduce_attribute( + states, light.ATTR_MAX_MIREDS, default=500, reduce=max + ) + + self._attr_effect_list = None + all_effect_lists = list( + helpers.find_state_attributes(states, light.ATTR_EFFECT_LIST) + ) if all_effect_lists: # Merge all effects from all effect_lists with a union merge. - self._effect_list = list(set().union(*all_effect_lists)) + self._attr_effect_list = list(set().union(*all_effect_lists)) - self._effect = None - all_effects = list(helpers.find_state_attributes(on_states, ATTR_EFFECT)) + self._attr_effect = None + all_effects = list(helpers.find_state_attributes(on_states, light.ATTR_EFFECT)) if all_effects: # Report the most common effect. effects_count = Counter(itertools.chain(all_effects)) - self._effect = effects_count.most_common(1)[0][0] + self._attr_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) + helpers.find_state_attributes(on_states, light.ATTR_COLOR_MODE) ) if all_color_modes: # Report the most common color mode, select brightness and onoff last @@ -745,7 +783,7 @@ class LightGroup(BaseLight, ZhaGroupEntity): self._attr_supported_color_modes = None all_supported_color_modes = list( - helpers.find_state_attributes(states, ATTR_SUPPORTED_COLOR_MODES) + helpers.find_state_attributes(states, light.ATTR_SUPPORTED_COLOR_MODES) ) if all_supported_color_modes: # Merge all color modes. @@ -753,14 +791,14 @@ class LightGroup(BaseLight, ZhaGroupEntity): set[str], set().union(*all_supported_color_modes) ) - self._supported_features = 0 + self._attr_supported_features = 0 for support in helpers.find_state_attributes(states, ATTR_SUPPORTED_FEATURES): # Merge supported features by emulating support for every feature # we find. - self._supported_features |= support + self._attr_supported_features |= support # Bitwise-and the supported features with the GroupedLight's features # so that we don't break in the future when a new feature is added. - self._supported_features &= SUPPORT_GROUP_LIGHT + self._attr_supported_features &= SUPPORT_GROUP_LIGHT async def _force_member_updates(self): """Force the update of member entities to ensure the states are correct for bulbs that don't report their state.""" diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_channels.py index 7701992cab4..6aba5500a2a 100644 --- a/tests/components/zha/test_channels.py +++ b/tests/components/zha/test_channels.py @@ -151,7 +151,18 @@ async def poll_control_device(zha_device_restored, zigpy_device_mock): }, ), (0x0202, 1, {"fan_mode"}), - (0x0300, 1, {"current_x", "current_y", "color_temperature"}), + ( + 0x0300, + 1, + { + "current_x", + "current_y", + "color_temperature", + "current_hue", + "enhanced_current_hue", + "current_saturation", + }, + ), (0x0400, 1, {"measured_value"}), (0x0401, 1, {"level_status"}), (0x0402, 1, {"measured_value"}), diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 982ff622341..f892448dc69 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -15,11 +15,7 @@ from homeassistant.components.light import ( ColorMode, ) from homeassistant.components.zha.core.group import GroupMember -from homeassistant.components.zha.light import ( - CAPABILITIES_COLOR_TEMP, - CAPABILITIES_COLOR_XY, - FLASH_EFFECTS, -) +from homeassistant.components.zha.light import FLASH_EFFECTS from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform import homeassistant.util.dt as dt_util @@ -148,7 +144,8 @@ async def device_light_1(hass, zigpy_device_mock, zha_device_joined): ) color_cluster = zigpy_device.endpoints[1].light_color color_cluster.PLUGGED_ATTR_READS = { - "color_capabilities": CAPABILITIES_COLOR_TEMP | CAPABILITIES_COLOR_XY + "color_capabilities": lighting.Color.ColorCapabilities.Color_temperature + | lighting.Color.ColorCapabilities.XY_attributes } zha_device = await zha_device_joined(zigpy_device) zha_device.available = True @@ -180,7 +177,8 @@ async def device_light_2(hass, zigpy_device_mock, zha_device_joined): ) color_cluster = zigpy_device.endpoints[1].light_color color_cluster.PLUGGED_ATTR_READS = { - "color_capabilities": CAPABILITIES_COLOR_TEMP | CAPABILITIES_COLOR_XY + "color_capabilities": lighting.Color.ColorCapabilities.Color_temperature + | lighting.Color.ColorCapabilities.XY_attributes } zha_device = await zha_device_joined(zigpy_device) zha_device.available = True @@ -239,7 +237,8 @@ async def eWeLink_light(hass, zigpy_device_mock, zha_device_joined): ) color_cluster = zigpy_device.endpoints[1].light_color color_cluster.PLUGGED_ATTR_READS = { - "color_capabilities": CAPABILITIES_COLOR_TEMP | CAPABILITIES_COLOR_XY + "color_capabilities": lighting.Color.ColorCapabilities.Color_temperature + | lighting.Color.ColorCapabilities.XY_attributes } zha_device = await zha_device_joined(zigpy_device) zha_device.available = True @@ -302,7 +301,7 @@ async def test_light_refresh(hass, zigpy_device_mock, zha_device_joined_restored ) @pytest.mark.parametrize( "device, reporting", - [(LIGHT_ON_OFF, (1, 0, 0)), (LIGHT_LEVEL, (1, 1, 0)), (LIGHT_COLOR, (1, 1, 3))], + [(LIGHT_ON_OFF, (1, 0, 0)), (LIGHT_LEVEL, (1, 1, 0)), (LIGHT_COLOR, (1, 1, 6))], ) async def test_light( hass, zigpy_device_mock, zha_device_joined_restored, device, reporting @@ -1400,7 +1399,7 @@ async def test_zha_group_light_entity( assert group_state.state == STATE_OFF assert group_state.attributes["supported_color_modes"] == [ ColorMode.COLOR_TEMP, - ColorMode.HS, + ColorMode.XY, ] # Light which is off has no color mode assert "color_mode" not in group_state.attributes @@ -1431,9 +1430,9 @@ async def test_zha_group_light_entity( assert group_state.state == STATE_ON assert group_state.attributes["supported_color_modes"] == [ ColorMode.COLOR_TEMP, - ColorMode.HS, + ColorMode.XY, ] - assert group_state.attributes["color_mode"] == ColorMode.HS + assert group_state.attributes["color_mode"] == ColorMode.XY # test long flashing the lights from the HA await async_test_flash_from_hass(