Use ExecuteIfOff on color cluster for supported bulbs with ZHA (#84874)

* Add options and execute_if_off_supported properties to Color channel

* Initialize "options" attribute on Color channel (allowing cache)

* Implement execute_if_off_supported for ZHA lights

* Make sure that color_channel exists, before checking execute_if_off_supported

* Replace "color_channel is not None" check with simplified "if color_channel"

* Make "test_number" test expect "options" for init attribute

* Add test_on_with_off_color test to test old and new behavior

* Experimental code to also support "execute_if_off" for groups if all members support it

* Remove support for groups for now

Group support will likely be added in a separate PR. For now, the old/standard behavior is used for groups.
This commit is contained in:
TheJulianJES 2023-01-23 13:58:18 +01:00 committed by GitHub
parent 29e3d06a42
commit 6582ee3591
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 196 additions and 12 deletions

View File

@ -45,6 +45,7 @@ class ColorChannel(ZigbeeChannel):
"color_capabilities": True,
"color_loop_active": False,
"start_up_color_temperature": True,
"options": True,
}
@cached_property
@ -167,3 +168,13 @@ class ColorChannel(ZigbeeChannel):
self.color_capabilities is not None
and lighting.Color.ColorCapabilities.Color_loop in self.color_capabilities
)
@property
def options(self) -> lighting.Color.Options:
"""Return ZCL options of the channel."""
return lighting.Color.Options(self.cluster.get("options", 0))
@property
def execute_if_off_supported(self) -> bool:
"""Return True if the channel can execute commands when off."""
return lighting.Color.Options.Execute_if_off in self.options

View File

@ -188,6 +188,12 @@ class BaseLight(LogMixin, light.LightEntity):
xy_color = kwargs.get(light.ATTR_XY_COLOR)
hs_color = kwargs.get(light.ATTR_HS_COLOR)
execute_if_off_supported = (
not isinstance(self, LightGroup)
and self._color_channel
and self._color_channel.execute_if_off_supported
)
set_transition_flag = (
brightness_supported(self._attr_supported_color_modes)
or temperature is not None
@ -254,6 +260,7 @@ class BaseLight(LogMixin, light.LightEntity):
)
)
and brightness_supported(self._attr_supported_color_modes)
and not execute_if_off_supported
)
if (
@ -288,6 +295,23 @@ class BaseLight(LogMixin, light.LightEntity):
# Currently only setting it to "on", as the correct level state will be set at the second move_to_level call
self._attr_state = True
if execute_if_off_supported:
self.debug("handling color commands before turning on/level")
if not await self.async_handle_color_commands(
temperature,
duration, # duration is ignored by lights when off
hs_color,
xy_color,
new_color_provided_while_off,
t_log,
):
# Color calls before on/level calls failed,
# so if the transitioning delay isn't running from a previous call, the flag can be unset immediately
if set_transition_flag and not self._transition_listener:
self.async_transition_complete()
self.debug("turned on: %s", t_log)
return
if (
(brightness is not None or transition)
and not new_color_provided_while_off
@ -326,18 +350,20 @@ class BaseLight(LogMixin, light.LightEntity):
return
self._attr_state = True
if not await self.async_handle_color_commands(
temperature,
duration,
hs_color,
xy_color,
new_color_provided_while_off,
t_log,
):
# Color calls failed, but as brightness may still transition, we start the timer to unset the flag
self.async_transition_start_timer(transition_time)
self.debug("turned on: %s", t_log)
return
if not execute_if_off_supported:
self.debug("handling color commands after turning on/level")
if not await self.async_handle_color_commands(
temperature,
duration,
hs_color,
xy_color,
new_color_provided_while_off,
t_log,
):
# Color calls failed, but as brightness may still transition, we start the timer to unset the flag
self.async_transition_start_timer(transition_time)
self.debug("turned on: %s", t_log)
return
if new_color_provided_while_off:
# The light is has the correct color, so we can now transition it to the correct brightness level.

View File

@ -34,6 +34,7 @@ from .common import (
get_zha_gateway,
patch_zha_config,
send_attributes_report,
update_attribute_cache,
)
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
@ -1199,6 +1200,151 @@ async def test_transitions(
assert eWeLink_state.attributes["max_mireds"] == 500
@patch(
"zigpy.zcl.clusters.lighting.Color.request",
new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
)
@patch(
"zigpy.zcl.clusters.general.LevelControl.request",
new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
)
@patch(
"zigpy.zcl.clusters.general.OnOff.request",
new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
)
async def test_on_with_off_color(hass, device_light_1):
"""Test turning on the light and sending color commands before on/level commands for supporting lights."""
device_1_entity_id = await find_entity_id(Platform.LIGHT, device_light_1, hass)
dev1_cluster_on_off = device_light_1.device.endpoints[1].on_off
dev1_cluster_level = device_light_1.device.endpoints[1].level
dev1_cluster_color = device_light_1.device.endpoints[1].light_color
# Execute_if_off will override the "enhanced turn on from an off-state" config option that's enabled here
dev1_cluster_color.PLUGGED_ATTR_READS = {
"options": lighting.Color.Options.Execute_if_off
}
update_attribute_cache(dev1_cluster_color)
# turn on via UI
dev1_cluster_on_off.request.reset_mock()
dev1_cluster_level.request.reset_mock()
dev1_cluster_color.request.reset_mock()
await hass.services.async_call(
LIGHT_DOMAIN,
"turn_on",
{
"entity_id": device_1_entity_id,
"color_temp": 235,
},
blocking=True,
)
assert dev1_cluster_on_off.request.call_count == 1
assert dev1_cluster_on_off.request.await_count == 1
assert dev1_cluster_color.request.call_count == 1
assert dev1_cluster_color.request.await_count == 1
assert dev1_cluster_level.request.call_count == 0
assert dev1_cluster_level.request.await_count == 0
assert dev1_cluster_on_off.request.call_args_list[0] == call(
False,
dev1_cluster_on_off.commands_by_name["on"].id,
dev1_cluster_on_off.commands_by_name["on"].schema,
expect_reply=True,
manufacturer=None,
tries=1,
tsn=None,
)
assert dev1_cluster_color.request.call_args == call(
False,
dev1_cluster_color.commands_by_name["move_to_color_temp"].id,
dev1_cluster_color.commands_by_name["move_to_color_temp"].schema,
color_temp_mireds=235,
transition_time=0,
expect_reply=True,
manufacturer=None,
tries=1,
tsn=None,
)
light1_state = hass.states.get(device_1_entity_id)
assert light1_state.state == STATE_ON
assert light1_state.attributes["color_temp"] == 235
assert light1_state.attributes["color_mode"] == ColorMode.COLOR_TEMP
# now let's turn off the Execute_if_off option and see if the old behavior is restored
dev1_cluster_color.PLUGGED_ATTR_READS = {"options": 0}
update_attribute_cache(dev1_cluster_color)
# turn off via UI, so the old "enhanced turn on from an off-state" behavior can do something
await async_test_off_from_hass(hass, dev1_cluster_on_off, device_1_entity_id)
# turn on via UI (with a different color temp, so the "enhanced turn on" does something)
dev1_cluster_on_off.request.reset_mock()
dev1_cluster_level.request.reset_mock()
dev1_cluster_color.request.reset_mock()
await hass.services.async_call(
LIGHT_DOMAIN,
"turn_on",
{
"entity_id": device_1_entity_id,
"color_temp": 240,
},
blocking=True,
)
assert dev1_cluster_on_off.request.call_count == 0
assert dev1_cluster_on_off.request.await_count == 0
assert dev1_cluster_color.request.call_count == 1
assert dev1_cluster_color.request.await_count == 1
assert dev1_cluster_level.request.call_count == 2
assert dev1_cluster_level.request.await_count == 2
# first it comes on with no transition at 2 brightness
assert dev1_cluster_level.request.call_args_list[0] == call(
False,
dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].id,
dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].schema,
level=2,
transition_time=0,
expect_reply=True,
manufacturer=None,
tries=1,
tsn=None,
)
assert dev1_cluster_color.request.call_args == call(
False,
dev1_cluster_color.commands_by_name["move_to_color_temp"].id,
dev1_cluster_color.commands_by_name["move_to_color_temp"].schema,
color_temp_mireds=240,
transition_time=0,
expect_reply=True,
manufacturer=None,
tries=1,
tsn=None,
)
assert dev1_cluster_level.request.call_args_list[1] == call(
False,
dev1_cluster_level.commands_by_name["move_to_level"].id,
dev1_cluster_level.commands_by_name["move_to_level"].schema,
level=254,
transition_time=0,
expect_reply=True,
manufacturer=None,
tries=1,
tsn=None,
)
light1_state = hass.states.get(device_1_entity_id)
assert light1_state.state == STATE_ON
assert light1_state.attributes["brightness"] == 254
assert light1_state.attributes["color_temp"] == 240
assert light1_state.attributes["color_mode"] == ColorMode.COLOR_TEMP
async def async_test_on_off_from_light(hass, cluster, entity_id):
"""Test on off functionality from the light."""
# turn on at light

View File

@ -358,6 +358,7 @@ async def test_color_number(
"color_temp_physical_max",
"color_capabilities",
"start_up_color_temperature",
"options",
],
allow_cache=True,
only_cache=False,