Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
11 changes: 11 additions & 0 deletions homeassistant/components/zha/core/channels/lighting.py
Expand Up @@ -45,6 +45,7 @@ class ColorChannel(ZigbeeChannel):
"color_capabilities": True,
"color_loop_active": False,
"start_up_color_temperature": True,
"options": True,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Explanation/Note:

Technically initializing this attribute isn't really needed at the moment, as the options parameter needs to be set manually anyway (for now) and the options property defaults to 0 if there isn't a cached attribute.

In the future, when a configuration entity is added for this attribute, it would be nice if that attribute was already read/initialized (so the configuration entity is created appropriately). (Hence why I added it here (cached, as it won't ever update on its own))

}

@cached_property
Expand Down Expand Up @@ -167,3 +168,13 @@ def color_loop_supported(self) -> bool:
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
50 changes: 38 additions & 12 deletions homeassistant/components/zha/light.py
Expand Up @@ -188,6 +188,12 @@ async def async_turn_on(self, **kwargs: Any) -> None:
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
Expand Down Expand Up @@ -254,6 +260,7 @@ async def async_turn_on(self, **kwargs: Any) -> None:
)
)
and brightness_supported(self._attr_supported_color_modes)
and not execute_if_off_supported
)

if (
Expand Down Expand Up @@ -288,6 +295,23 @@ async def async_turn_on(self, **kwargs: Any) -> None:
# 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
Expand Down Expand Up @@ -326,18 +350,20 @@ async def async_turn_on(self, **kwargs: Any) -> None:
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.
Expand Down
146 changes: 146 additions & 0 deletions tests/components/zha/test_light.py
Expand Up @@ -34,6 +34,7 @@
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

Expand Down Expand Up @@ -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,
)
Comment on lines +1251 to +1270
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Question regarding tests:

It's not too important here, but how would I check the message order? (messages are sent on different clusters)
(That code doesn't care whether "on" or "move_to_color_temp" is sent first)


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
Expand Down
1 change: 1 addition & 0 deletions tests/components/zha/test_number.py
Expand Up @@ -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,
Expand Down