Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions homeassistant/components/zha/core/channels/lighting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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."""
Expand Down
116 changes: 69 additions & 47 deletions homeassistant/components/zha/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@

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
Expand All @@ -17,16 +16,17 @@
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 (
Expand Down Expand Up @@ -86,24 +86,14 @@
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,
Expand Down Expand Up @@ -146,6 +136,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]:
Expand All @@ -160,6 +151,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."""
Expand Down Expand Up @@ -230,9 +226,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:
Expand All @@ -257,10 +253,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

@emontnemery emontnemery Apr 28, 2022

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard was not needed, it's handled by the light base component

):
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
Expand All @@ -270,10 +263,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

@emontnemery emontnemery Apr 28, 2022

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard was not needed, it's handled by the light base component
Same comment for the removed guards below.

):
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(
Expand All @@ -286,10 +276,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
Expand All @@ -302,9 +289,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,
Expand All @@ -316,10 +301,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
)
Expand All @@ -332,7 +314,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(
Expand All @@ -356,6 +338,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):
Expand All @@ -372,19 +355,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.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:
Expand All @@ -399,6 +383,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 == 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
Expand Down Expand Up @@ -455,6 +449,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 = 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:
Expand Down Expand Up @@ -493,12 +489,14 @@ 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:
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:
Expand Down Expand Up @@ -573,6 +571,7 @@ def __init__(
CONF_DEFAULT_LIGHT_TRANSITION,
0,
)
self._color_mode = None

async def async_added_to_hass(self):
"""Run when about to be added to hass."""
Expand Down Expand Up @@ -633,6 +632,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
Expand Down
12 changes: 11 additions & 1 deletion tests/components/zha/test_light.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down