diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py index a1f7c6df7e1..55d77d507fd 100644 --- a/homeassistant/components/zha/core/channels/lighting.py +++ b/homeassistant/components/zha/core/channels/lighting.py @@ -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 diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 4be2c910f22..16747869efc 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -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. diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 8d48b061ac4..c5c8574e8dd 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -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 diff --git a/tests/components/zha/test_number.py b/tests/components/zha/test_number.py index b1f11d389b1..483ede70988 100644 --- a/tests/components/zha/test_number.py +++ b/tests/components/zha/test_number.py @@ -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,