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 <david.mulcahey@me.com>
This commit is contained in:
Erik Montnemery 2022-05-27 15:38:22 +02:00 committed by GitHub
parent 35bc6900ea
commit 5ca82b2d33
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 86 additions and 48 deletions

View File

@ -36,6 +36,7 @@ class ColorChannel(ZigbeeChannel):
MAX_MIREDS: int = 500 MAX_MIREDS: int = 500
MIN_MIREDS: int = 153 MIN_MIREDS: int = 153
ZCL_INIT_ATTRS = { ZCL_INIT_ATTRS = {
"color_mode": False,
"color_temp_physical_min": True, "color_temp_physical_min": True,
"color_temp_physical_max": True, "color_temp_physical_max": True,
"color_capabilities": 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 | self.CAPABILITIES_COLOR_TEMP
return self.CAPABILITIES_COLOR_XY 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 @property
def color_loop_active(self) -> int | None: def color_loop_active(self) -> int | None:
"""Return cached value of the color_loop_active attribute.""" """Return cached value of the color_loop_active attribute."""

View File

@ -3,12 +3,11 @@ from __future__ import annotations
from collections import Counter from collections import Counter
from datetime import timedelta from datetime import timedelta
import enum
import functools import functools
import itertools import itertools
import logging import logging
import random import random
from typing import Any from typing import Any, cast
from zigpy.zcl.clusters.general import Identify, LevelControl, OnOff from zigpy.zcl.clusters.general import Identify, LevelControl, OnOff
from zigpy.zcl.clusters.lighting import Color 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 import light
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
ATTR_COLOR_MODE,
ATTR_COLOR_TEMP, ATTR_COLOR_TEMP,
ATTR_EFFECT, ATTR_EFFECT,
ATTR_EFFECT_LIST, ATTR_EFFECT_LIST,
ATTR_HS_COLOR, ATTR_HS_COLOR,
ATTR_MAX_MIREDS, ATTR_MAX_MIREDS,
ATTR_MIN_MIREDS, ATTR_MIN_MIREDS,
SUPPORT_BRIGHTNESS, ATTR_SUPPORTED_COLOR_MODES,
SUPPORT_COLOR, ColorMode,
SUPPORT_COLOR_TEMP, brightness_supported,
LightEntityFeature, filter_supported_color_modes,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
@ -86,24 +86,14 @@ GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.LIGHT)
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
SIGNAL_LIGHT_GROUP_STATE_CHANGED = "zha_light_group_state_changed" SIGNAL_LIGHT_GROUP_STATE_CHANGED = "zha_light_group_state_changed"
COLOR_MODES_GROUP_LIGHT = {ColorMode.COLOR_TEMP, ColorMode.HS}
SUPPORT_GROUP_LIGHT = ( SUPPORT_GROUP_LIGHT = (
SUPPORT_BRIGHTNESS light.LightEntityFeature.EFFECT
| SUPPORT_COLOR_TEMP | light.LightEntityFeature.FLASH
| LightEntityFeature.EFFECT | light.LightEntityFeature.TRANSITION
| LightEntityFeature.FLASH
| SUPPORT_COLOR
| 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: ConfigEntry,
@ -146,6 +136,7 @@ class BaseLight(LogMixin, light.LightEntity):
self._color_channel = None self._color_channel = None
self._identify_channel = None self._identify_channel = None
self._default_transition = None self._default_transition = None
self._color_mode = ColorMode.UNKNOWN # Set by sub classes
@property @property
def extra_state_attributes(self) -> dict[str, Any]: def extra_state_attributes(self) -> dict[str, Any]:
@ -160,6 +151,11 @@ class BaseLight(LogMixin, light.LightEntity):
return False return False
return self._state return self._state
@property
def color_mode(self):
"""Return the color mode of this light."""
return self._color_mode
@property @property
def brightness(self): def brightness(self):
"""Return the brightness of this light.""" """Return the brightness of this light."""
@ -230,9 +226,9 @@ class BaseLight(LogMixin, light.LightEntity):
brightness = self._off_brightness brightness = self._off_brightness
t_log = {} t_log = {}
if ( if (brightness is not None or transition) and brightness_supported(
brightness is not None or transition self._attr_supported_color_modes
) and self._supported_features & light.SUPPORT_BRIGHTNESS: ):
if brightness is not None: if brightness is not None:
level = min(254, brightness) level = min(254, brightness)
else: else:
@ -257,10 +253,7 @@ class BaseLight(LogMixin, light.LightEntity):
self.debug("turned on: %s", t_log) self.debug("turned on: %s", t_log)
return return
self._state = True self._state = True
if ( if light.ATTR_COLOR_TEMP in kwargs:
light.ATTR_COLOR_TEMP in kwargs
and self.supported_features & light.SUPPORT_COLOR_TEMP
):
temperature = kwargs[light.ATTR_COLOR_TEMP] temperature = kwargs[light.ATTR_COLOR_TEMP]
result = await self._color_channel.move_to_color_temp(temperature, duration) result = await self._color_channel.move_to_color_temp(temperature, duration)
t_log["move_to_color_temp"] = result t_log["move_to_color_temp"] = result
@ -270,10 +263,7 @@ class BaseLight(LogMixin, light.LightEntity):
self._color_temp = temperature self._color_temp = temperature
self._hs_color = None self._hs_color = None
if ( if light.ATTR_HS_COLOR in kwargs:
light.ATTR_HS_COLOR in kwargs
and self.supported_features & light.SUPPORT_COLOR
):
hs_color = kwargs[light.ATTR_HS_COLOR] hs_color = kwargs[light.ATTR_HS_COLOR]
xy_color = color_util.color_hs_to_xy(*hs_color) xy_color = color_util.color_hs_to_xy(*hs_color)
result = await self._color_channel.move_to_color( result = await self._color_channel.move_to_color(
@ -286,10 +276,7 @@ class BaseLight(LogMixin, light.LightEntity):
self._hs_color = hs_color self._hs_color = hs_color
self._color_temp = None self._color_temp = None
if ( if effect == light.EFFECT_COLORLOOP:
effect == light.EFFECT_COLORLOOP
and self.supported_features & light.LightEntityFeature.EFFECT
):
result = await self._color_channel.color_loop_set( result = await self._color_channel.color_loop_set(
UPDATE_COLORLOOP_ACTION UPDATE_COLORLOOP_ACTION
| UPDATE_COLORLOOP_DIRECTION | UPDATE_COLORLOOP_DIRECTION
@ -302,9 +289,7 @@ class BaseLight(LogMixin, light.LightEntity):
t_log["color_loop_set"] = result t_log["color_loop_set"] = result
self._effect = light.EFFECT_COLORLOOP self._effect = light.EFFECT_COLORLOOP
elif ( elif (
self._effect == light.EFFECT_COLORLOOP self._effect == light.EFFECT_COLORLOOP and effect != light.EFFECT_COLORLOOP
and effect != light.EFFECT_COLORLOOP
and self.supported_features & light.LightEntityFeature.EFFECT
): ):
result = await self._color_channel.color_loop_set( result = await self._color_channel.color_loop_set(
UPDATE_COLORLOOP_ACTION, UPDATE_COLORLOOP_ACTION,
@ -316,10 +301,7 @@ class BaseLight(LogMixin, light.LightEntity):
t_log["color_loop_set"] = result t_log["color_loop_set"] = result
self._effect = None self._effect = None
if ( if flash is not None:
flash is not None
and self._supported_features & light.LightEntityFeature.FLASH
):
result = await self._identify_channel.trigger_effect( result = await self._identify_channel.trigger_effect(
FLASH_EFFECTS[flash], EFFECT_DEFAULT_VARIANT FLASH_EFFECTS[flash], EFFECT_DEFAULT_VARIANT
) )
@ -332,7 +314,7 @@ class BaseLight(LogMixin, light.LightEntity):
async def async_turn_off(self, **kwargs): async def async_turn_off(self, **kwargs):
"""Turn the entity off.""" """Turn the entity off."""
duration = kwargs.get(light.ATTR_TRANSITION) 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: if duration and supports_level:
result = await self._level_channel.move_to_level_with_on_off( result = await self._level_channel.move_to_level_with_on_off(
@ -356,6 +338,7 @@ class BaseLight(LogMixin, light.LightEntity):
class Light(BaseLight, ZhaEntity): class Light(BaseLight, ZhaEntity):
"""Representation of a ZHA or ZLL light.""" """Representation of a ZHA or ZLL light."""
_attr_supported_color_modes: set(ColorMode)
_REFRESH_INTERVAL = (45, 75) _REFRESH_INTERVAL = (45, 75)
def __init__(self, unique_id, zha_device: ZhaDeviceType, channels, **kwargs): def __init__(self, unique_id, zha_device: ZhaDeviceType, channels, **kwargs):
@ -372,19 +355,20 @@ class Light(BaseLight, ZhaEntity):
self._cancel_refresh_handle = None self._cancel_refresh_handle = None
effect_list = [] effect_list = []
self._attr_supported_color_modes = {ColorMode.ONOFF}
if self._level_channel: 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._supported_features |= light.LightEntityFeature.TRANSITION
self._brightness = self._level_channel.current_level self._brightness = self._level_channel.current_level
if self._color_channel: if self._color_channel:
color_capabilities = self._color_channel.color_capabilities color_capabilities = self._color_channel.color_capabilities
if color_capabilities & CAPABILITIES_COLOR_TEMP: 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 self._color_temp = self._color_channel.color_temperature
if color_capabilities & CAPABILITIES_COLOR_XY: 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_x = self._color_channel.current_x
curr_y = self._color_channel.current_y curr_y = self._color_channel.current_y
if curr_x is not None and curr_y is not None: 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) effect_list.append(light.EFFECT_COLORLOOP)
if self._color_channel.color_loop_active == 1: if self._color_channel.color_loop_active == 1:
self._effect = light.EFFECT_COLORLOOP 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: if self._identify_channel:
self._supported_features |= light.LightEntityFeature.FLASH self._supported_features |= light.LightEntityFeature.FLASH
@ -455,6 +449,8 @@ class Light(BaseLight, ZhaEntity):
self._brightness = last_state.attributes["brightness"] self._brightness = last_state.attributes["brightness"]
if "off_brightness" in last_state.attributes: if "off_brightness" in last_state.attributes:
self._off_brightness = last_state.attributes["off_brightness"] 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: if "color_temp" in last_state.attributes:
self._color_temp = last_state.attributes["color_temp"] self._color_temp = last_state.attributes["color_temp"]
if "hs_color" in last_state.attributes: 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 := 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") color_temp = results.get("color_temperature")
if color_temp is not None and color_mode: if color_temp is not None and color_mode:
self._color_temp = color_temp self._color_temp = color_temp
self._hs_color = None self._hs_color = None
else: else:
self._color_mode = ColorMode.HS
color_x = results.get("current_x") color_x = results.get("current_x")
color_y = results.get("current_y") color_y = results.get("current_y")
if color_x is not None and color_y is not None: if color_x is not None and color_y is not None:
@ -573,6 +571,7 @@ class LightGroup(BaseLight, ZhaGroupEntity):
CONF_DEFAULT_LIGHT_TRANSITION, CONF_DEFAULT_LIGHT_TRANSITION,
0, 0,
) )
self._color_mode = None
async def async_added_to_hass(self): async def async_added_to_hass(self):
"""Run when about to be added to hass.""" """Run when about to be added to hass."""
@ -633,6 +632,29 @@ class LightGroup(BaseLight, ZhaGroupEntity):
effects_count = Counter(itertools.chain(all_effects)) effects_count = Counter(itertools.chain(all_effects))
self._effect = effects_count.most_common(1)[0][0] 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 self._supported_features = 0
for support in helpers.find_state_attributes(states, ATTR_SUPPORTED_FEATURES): for support in helpers.find_state_attributes(states, ATTR_SUPPORTED_FEATURES):
# Merge supported features by emulating support for every feature # Merge supported features by emulating support for every feature

View File

@ -12,6 +12,7 @@ from homeassistant.components.light import (
DOMAIN as LIGHT_DOMAIN, DOMAIN as LIGHT_DOMAIN,
FLASH_LONG, FLASH_LONG,
FLASH_SHORT, FLASH_SHORT,
ColorMode,
) )
from homeassistant.components.zha.core.group import GroupMember from homeassistant.components.zha.core.group import GroupMember
from homeassistant.components.zha.light import FLASH_EFFECTS 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) await async_wait_for_updates(hass)
# test that the lights were created and are off # 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 # 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) 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( await async_test_dimmer_from_light(
hass, dev1_cluster_level, group_entity_id, 150, STATE_ON 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 # test long flashing the lights from the HA
await async_test_flash_from_hass( await async_test_flash_from_hass(