From fdaaed652384c43a0e8f17f630fa82fe20bc59cb Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Mon, 18 Jul 2022 10:20:49 -0400 Subject: [PATCH] Fix ZHA light turn on issues (#75220) * rename variable * default transition is for color commands not level * no extra command for groups * don't transition color change when light off -> on * clean up * update condition * fix condition again... * simplify * simplify * missed one * rename * simplify * rename * tests * color_provided_while_off with no changes * fix missing flag clear * more tests for transition scenarios * add to comment * fix comment * don't transition when force on is set * stale comment * dont transition when colors don't change * remove extra line * remove debug print :) * fix colors * restore color to 65535 until investigated --- homeassistant/components/zha/light.py | 98 ++-- tests/components/zha/test_light.py | 811 +++++++++++++++++++++++++- 2 files changed, 868 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index e1c85b39d8e..cc8dc475c43 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -73,7 +73,6 @@ CAPABILITIES_COLOR_LOOP = 0x4 CAPABILITIES_COLOR_XY = 0x08 CAPABILITIES_COLOR_TEMP = 0x10 -DEFAULT_TRANSITION = 1 DEFAULT_MIN_BRIGHTNESS = 2 UPDATE_COLORLOOP_ACTION = 0x1 @@ -119,7 +118,7 @@ class BaseLight(LogMixin, light.LightEntity): """Operations common to all light entities.""" _FORCE_ON = False - _DEFAULT_COLOR_FROM_OFF_TRANSITION = 0 + _DEFAULT_MIN_TRANSITION_TIME = 0 def __init__(self, *args, **kwargs): """Initialize the light.""" @@ -140,7 +139,7 @@ class BaseLight(LogMixin, light.LightEntity): self._level_channel = None self._color_channel = None self._identify_channel = None - self._default_transition = None + self._zha_config_transition = self._DEFAULT_MIN_TRANSITION_TIME self._attr_color_mode = ColorMode.UNKNOWN # Set by sub classes @property @@ -216,33 +215,49 @@ class BaseLight(LogMixin, light.LightEntity): transition = kwargs.get(light.ATTR_TRANSITION) duration = ( transition * 10 - if transition - else self._default_transition * 10 - if self._default_transition - else DEFAULT_TRANSITION - ) + if transition is not None + else self._zha_config_transition * 10 + ) or self._DEFAULT_MIN_TRANSITION_TIME # if 0 is passed in some devices still need the minimum default brightness = kwargs.get(light.ATTR_BRIGHTNESS) effect = kwargs.get(light.ATTR_EFFECT) flash = kwargs.get(light.ATTR_FLASH) + temperature = kwargs.get(light.ATTR_COLOR_TEMP) + hs_color = kwargs.get(light.ATTR_HS_COLOR) # If the light is currently off but a turn_on call with a color/temperature is sent, # the light needs to be turned on first at a low brightness level where the light is immediately transitioned # to the correct color. Afterwards, the transition is only from the low brightness to the new brightness. # Otherwise, the transition is from the color the light had before being turned on to the new color. - # This can look especially bad with transitions longer than a second. - color_provided_from_off = ( - not self._state + # This can look especially bad with transitions longer than a second. We do not want to do this for + # devices that need to be forced to use the on command because we would end up with 4 commands sent: + # move to level, on, color, move to level... We also will not set this if the bulb is already in the + # desired color mode with the desired color or color temperature. + new_color_provided_while_off = ( + not isinstance(self, LightGroup) + and not self._FORCE_ON + and not self._state + and ( + ( + temperature is not None + and ( + self._color_temp != temperature + or self._attr_color_mode != ColorMode.COLOR_TEMP + ) + ) + or ( + hs_color is not None + and ( + self.hs_color != hs_color + or self._attr_color_mode != ColorMode.HS + ) + ) + ) and brightness_supported(self._attr_supported_color_modes) - and (light.ATTR_COLOR_TEMP in kwargs or light.ATTR_HS_COLOR in kwargs) ) - final_duration = duration - if color_provided_from_off: - # Set the duration for the color changing commands to 0. - duration = 0 if ( brightness is None - and (self._off_with_transition or color_provided_from_off) + and (self._off_with_transition or new_color_provided_while_off) and self._off_brightness is not None ): brightness = self._off_brightness @@ -254,11 +269,11 @@ class BaseLight(LogMixin, light.LightEntity): t_log = {} - if color_provided_from_off: + if new_color_provided_while_off: # If the light is currently off, we first need to turn it on at a low brightness level with no transition. # After that, we set it to the desired color/temperature with no transition. result = await self._level_channel.move_to_level_with_on_off( - DEFAULT_MIN_BRIGHTNESS, self._DEFAULT_COLOR_FROM_OFF_TRANSITION + DEFAULT_MIN_BRIGHTNESS, self._DEFAULT_MIN_TRANSITION_TIME ) t_log["move_to_level_with_on_off"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -269,7 +284,7 @@ class BaseLight(LogMixin, light.LightEntity): if ( (brightness is not None or transition) - and not color_provided_from_off + and not new_color_provided_while_off and brightness_supported(self._attr_supported_color_modes) ): result = await self._level_channel.move_to_level_with_on_off( @@ -285,7 +300,7 @@ class BaseLight(LogMixin, light.LightEntity): if ( brightness is None - and not color_provided_from_off + and not new_color_provided_while_off or (self._FORCE_ON and brightness) ): # since some lights don't always turn on with move_to_level_with_on_off, @@ -297,9 +312,13 @@ class BaseLight(LogMixin, light.LightEntity): return self._state = True - if light.ATTR_COLOR_TEMP in kwargs: - temperature = kwargs[light.ATTR_COLOR_TEMP] - result = await self._color_channel.move_to_color_temp(temperature, duration) + if temperature is not None: + result = await self._color_channel.move_to_color_temp( + temperature, + self._DEFAULT_MIN_TRANSITION_TIME + if new_color_provided_while_off + else duration, + ) t_log["move_to_color_temp"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) @@ -308,11 +327,14 @@ class BaseLight(LogMixin, light.LightEntity): self._color_temp = temperature self._hs_color = None - if light.ATTR_HS_COLOR in kwargs: - hs_color = kwargs[light.ATTR_HS_COLOR] + if hs_color is not None: xy_color = color_util.color_hs_to_xy(*hs_color) result = await self._color_channel.move_to_color( - int(xy_color[0] * 65535), int(xy_color[1] * 65535), duration + int(xy_color[0] * 65535), + int(xy_color[1] * 65535), + self._DEFAULT_MIN_TRANSITION_TIME + if new_color_provided_while_off + else duration, ) t_log["move_to_color"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -322,9 +344,9 @@ class BaseLight(LogMixin, light.LightEntity): self._hs_color = hs_color self._color_temp = None - if color_provided_from_off: + if new_color_provided_while_off: # The light is has the correct color, so we can now transition it to the correct brightness level. - result = await self._level_channel.move_to_level(level, final_duration) + result = await self._level_channel.move_to_level(level, duration) t_log["move_to_level_if_color"] = result if isinstance(result, Exception) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) @@ -371,12 +393,13 @@ class BaseLight(LogMixin, light.LightEntity): async def async_turn_off(self, **kwargs): """Turn the entity off.""" - duration = kwargs.get(light.ATTR_TRANSITION) + transition = kwargs.get(light.ATTR_TRANSITION) supports_level = brightness_supported(self._attr_supported_color_modes) - if duration and supports_level: + # is not none looks odd here but it will override built in bulb transition times if we pass 0 in here + if transition is not None and supports_level: result = await self._level_channel.move_to_level_with_on_off( - 0, duration * 10 + 0, transition * 10 ) else: result = await self._on_off_channel.off() @@ -387,7 +410,7 @@ class BaseLight(LogMixin, light.LightEntity): if supports_level: # store current brightness so that the next turn_on uses it. - self._off_with_transition = bool(duration) + self._off_with_transition = transition is not None self._off_brightness = self._brightness self.async_write_ha_state() @@ -460,7 +483,7 @@ class Light(BaseLight, ZhaEntity): if effect_list: self._effect_list = effect_list - self._default_transition = async_get_zha_config_value( + self._zha_config_transition = async_get_zha_config_value( zha_device.gateway.config_entry, ZHA_OPTIONS, CONF_DEFAULT_LIGHT_TRANSITION, @@ -472,6 +495,7 @@ class Light(BaseLight, ZhaEntity): """Set the state.""" self._state = bool(value) if value: + self._off_with_transition = False self._off_brightness = None self.async_write_ha_state() @@ -605,7 +629,7 @@ class HueLight(Light): @STRICT_MATCH( channel_names=CHANNEL_ON_OFF, aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}, - manufacturers={"Jasco", "Quotra-Vision"}, + manufacturers={"Jasco", "Quotra-Vision", "eWeLight", "eWeLink"}, ) class ForceOnLight(Light): """Representation of a light which does not respect move_to_level_with_on_off.""" @@ -621,7 +645,7 @@ class ForceOnLight(Light): class SengledLight(Light): """Representation of a Sengled light which does not react to move_to_color_temp with 0 as a transition.""" - _DEFAULT_COLOR_FROM_OFF_TRANSITION = 1 + _DEFAULT_MIN_TRANSITION_TIME = 1 @GROUP_MATCH() @@ -639,7 +663,7 @@ class LightGroup(BaseLight, ZhaGroupEntity): self._color_channel = group.endpoint[Color.cluster_id] self._identify_channel = group.endpoint[Identify.cluster_id] self._debounced_member_refresh = None - self._default_transition = async_get_zha_config_value( + self._zha_config_transition = async_get_zha_config_value( zha_device.gateway.config_entry, ZHA_OPTIONS, CONF_DEFAULT_LIGHT_TRANSITION, diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index dd6df0dff19..982ff622341 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -15,7 +15,11 @@ from homeassistant.components.light import ( ColorMode, ) from homeassistant.components.zha.core.group import GroupMember -from homeassistant.components.zha.light import FLASH_EFFECTS +from homeassistant.components.zha.light import ( + CAPABILITIES_COLOR_TEMP, + CAPABILITIES_COLOR_XY, + FLASH_EFFECTS, +) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform import homeassistant.util.dt as dt_util @@ -142,6 +146,10 @@ async def device_light_1(hass, zigpy_device_mock, zha_device_joined): ieee=IEEE_GROUPABLE_DEVICE, nwk=0xB79D, ) + color_cluster = zigpy_device.endpoints[1].light_color + color_cluster.PLUGGED_ATTR_READS = { + "color_capabilities": CAPABILITIES_COLOR_TEMP | CAPABILITIES_COLOR_XY + } zha_device = await zha_device_joined(zigpy_device) zha_device.available = True return zha_device @@ -167,8 +175,13 @@ async def device_light_2(hass, zigpy_device_mock, zha_device_joined): } }, ieee=IEEE_GROUPABLE_DEVICE2, + manufacturer="Sengled", nwk=0xC79E, ) + color_cluster = zigpy_device.endpoints[1].light_color + color_cluster.PLUGGED_ATTR_READS = { + "color_capabilities": CAPABILITIES_COLOR_TEMP | CAPABILITIES_COLOR_XY + } zha_device = await zha_device_joined(zigpy_device) zha_device.available = True return zha_device @@ -201,6 +214,38 @@ async def device_light_3(hass, zigpy_device_mock, zha_device_joined): return zha_device +@pytest.fixture +async def eWeLink_light(hass, zigpy_device_mock, zha_device_joined): + """Mock eWeLink light.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.OnOff.cluster_id, + general.LevelControl.cluster_id, + lighting.Color.cluster_id, + general.Groups.cluster_id, + general.Identify.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ieee="03:2d:6f:00:0a:90:69:e3", + manufacturer="eWeLink", + nwk=0xB79D, + ) + color_cluster = zigpy_device.endpoints[1].light_color + color_cluster.PLUGGED_ATTR_READS = { + "color_capabilities": CAPABILITIES_COLOR_TEMP | CAPABILITIES_COLOR_XY + } + zha_device = await zha_device_joined(zigpy_device) + zha_device.available = True + return zha_device + + async def test_light_refresh(hass, zigpy_device_mock, zha_device_joined_restored): """Test zha light platform refresh.""" @@ -323,6 +368,758 @@ async def test_light( await async_test_flash_from_hass(hass, cluster_identify, entity_id, FLASH_LONG) +@patch( + "zigpy.zcl.clusters.lighting.Color.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "zigpy.zcl.clusters.general.Identify.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_transitions( + hass, device_light_1, device_light_2, eWeLink_light, coordinator +): + """Test ZHA light transition code.""" + zha_gateway = get_zha_gateway(hass) + assert zha_gateway is not None + zha_gateway.coordinator_zha_device = coordinator + coordinator._zha_gateway = zha_gateway + device_light_1._zha_gateway = zha_gateway + device_light_2._zha_gateway = zha_gateway + member_ieee_addresses = [device_light_1.ieee, device_light_2.ieee] + members = [GroupMember(device_light_1.ieee, 1), GroupMember(device_light_2.ieee, 1)] + + assert coordinator.is_coordinator + + # test creating a group with 2 members + zha_group = await zha_gateway.async_create_zigpy_group("Test Group", members) + await hass.async_block_till_done() + + assert zha_group is not None + assert len(zha_group.members) == 2 + for member in zha_group.members: + assert member.device.ieee in member_ieee_addresses + assert member.group == zha_group + assert member.endpoint is not None + + device_1_entity_id = await find_entity_id(Platform.LIGHT, device_light_1, hass) + device_2_entity_id = await find_entity_id(Platform.LIGHT, device_light_2, hass) + eWeLink_light_entity_id = await find_entity_id(Platform.LIGHT, eWeLink_light, hass) + assert device_1_entity_id != device_2_entity_id + + group_entity_id = async_find_group_entity_id(hass, Platform.LIGHT, zha_group) + assert hass.states.get(group_entity_id) is not None + + assert device_1_entity_id in zha_group.member_entity_ids + assert device_2_entity_id in zha_group.member_entity_ids + + dev1_cluster_on_off = device_light_1.device.endpoints[1].on_off + dev2_cluster_on_off = device_light_2.device.endpoints[1].on_off + eWeLink_cluster_on_off = eWeLink_light.device.endpoints[1].on_off + + dev1_cluster_level = device_light_1.device.endpoints[1].level + dev2_cluster_level = device_light_2.device.endpoints[1].level + eWeLink_cluster_level = eWeLink_light.device.endpoints[1].level + + dev1_cluster_color = device_light_1.device.endpoints[1].light_color + dev2_cluster_color = device_light_2.device.endpoints[1].light_color + eWeLink_cluster_color = eWeLink_light.device.endpoints[1].light_color + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [device_light_1, device_light_2]) + await async_wait_for_updates(hass) + + # test that the lights were created and are off + group_state = hass.states.get(group_entity_id) + assert group_state.state == STATE_OFF + light1_state = hass.states.get(device_1_entity_id) + assert light1_state.state == STATE_OFF + light2_state = hass.states.get(device_2_entity_id) + assert light2_state.state == STATE_OFF + + # first test 0 length transition with no color provided + dev1_cluster_on_off.request.reset_mock() + dev1_cluster_level.request.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {"entity_id": device_1_entity_id, "transition": 0, "brightness": 50}, + 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 == 0 + assert dev1_cluster_color.request.await_count == 0 + assert dev1_cluster_level.request.call_count == 1 + assert dev1_cluster_level.request.await_count == 1 + assert dev1_cluster_level.request.call_args == call( + False, + 4, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 50, # brightness (level in ZCL) + 0, # transition time + 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"] == 50 + + dev1_cluster_level.request.reset_mock() + + # test non 0 length transition with color provided while light is on + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + { + "entity_id": device_1_entity_id, + "transition": 3, + "brightness": 18, + "color_temp": 432, + }, + 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 == 1 + assert dev1_cluster_level.request.await_count == 1 + assert dev1_cluster_level.request.call_args == call( + False, + 4, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 18, # brightness (level in ZCL) + 30, # transition time (ZCL time in 10ths of a second) + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + assert dev1_cluster_color.request.call_args == call( + False, + 10, + dev1_cluster_color.commands_by_name["move_to_color_temp"].schema, + 432, # color temp mireds + 30.0, # transition time (ZCL time in 10ths of a second) + 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"] == 18 + assert light1_state.attributes["color_temp"] == 432 + assert light1_state.attributes["color_mode"] == ColorMode.COLOR_TEMP + + dev1_cluster_level.request.reset_mock() + dev1_cluster_color.request.reset_mock() + + # test 0 length transition to turn light off + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_off", + { + "entity_id": device_1_entity_id, + "transition": 0, + }, + 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 == 0 + assert dev1_cluster_color.request.await_count == 0 + assert dev1_cluster_level.request.call_count == 1 + assert dev1_cluster_level.request.await_count == 1 + assert dev1_cluster_level.request.call_args == call( + False, + 4, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 0, # brightness (level in ZCL) + 0, # transition time (ZCL time in 10ths of a second) + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + light1_state = hass.states.get(device_1_entity_id) + assert light1_state.state == STATE_OFF + + dev1_cluster_level.request.reset_mock() + + # test non 0 length transition and color temp while turning light on (color_provided_while_off) + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + { + "entity_id": device_1_entity_id, + "transition": 1, + "brightness": 25, + "color_temp": 235, + }, + 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, + 4, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 2, # brightness (level in ZCL) + 0, # transition time (ZCL time in 10ths of a second) + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + assert dev1_cluster_color.request.call_args == call( + False, + 10, + dev1_cluster_color.commands_by_name["move_to_color_temp"].schema, + 235, # color temp mireds + 0, # transition time (ZCL time in 10ths of a second) - no transition when color_provided_while_off + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + assert dev1_cluster_level.request.call_args_list[1] == call( + False, + 0, + dev1_cluster_level.commands_by_name["move_to_level"].schema, + 25, # brightness (level in ZCL) + 10.0, # transition time (ZCL time in 10ths of a second) + 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"] == 25 + assert light1_state.attributes["color_temp"] == 235 + assert light1_state.attributes["color_mode"] == ColorMode.COLOR_TEMP + + dev1_cluster_level.request.reset_mock() + dev1_cluster_color.request.reset_mock() + + # turn light 1 back off + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_off", + { + "entity_id": device_1_entity_id, + }, + 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 == 0 + assert dev1_cluster_color.request.await_count == 0 + assert dev1_cluster_level.request.call_count == 0 + assert dev1_cluster_level.request.await_count == 0 + group_state = hass.states.get(group_entity_id) + assert group_state.state == STATE_OFF + + dev1_cluster_on_off.request.reset_mock() + dev1_cluster_color.request.reset_mock() + dev1_cluster_level.request.reset_mock() + + # test no transition provided and color temp while turning light on (color_provided_while_off) + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + { + "entity_id": device_1_entity_id, + "brightness": 25, + "color_temp": 236, + }, + 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, + 4, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 2, # brightness (level in ZCL) + 0, # transition time (ZCL time in 10ths of a second) + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + assert dev1_cluster_color.request.call_args == call( + False, + 10, + dev1_cluster_color.commands_by_name["move_to_color_temp"].schema, + 236, # color temp mireds + 0, # transition time (ZCL time in 10ths of a second) - no transition when color_provided_while_off + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + assert dev1_cluster_level.request.call_args_list[1] == call( + False, + 0, + dev1_cluster_level.commands_by_name["move_to_level"].schema, + 25, # brightness (level in ZCL) + 0, # transition time (ZCL time in 10ths of a second) + 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"] == 25 + assert light1_state.attributes["color_temp"] == 236 + assert light1_state.attributes["color_mode"] == ColorMode.COLOR_TEMP + + dev1_cluster_level.request.reset_mock() + dev1_cluster_color.request.reset_mock() + + # turn light 1 back off to setup group test + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_off", + { + "entity_id": device_1_entity_id, + }, + 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 == 0 + assert dev1_cluster_color.request.await_count == 0 + assert dev1_cluster_level.request.call_count == 0 + assert dev1_cluster_level.request.await_count == 0 + group_state = hass.states.get(group_entity_id) + assert group_state.state == STATE_OFF + + dev1_cluster_on_off.request.reset_mock() + dev1_cluster_color.request.reset_mock() + dev1_cluster_level.request.reset_mock() + + # test no transition when the same color temp is provided from off + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + { + "entity_id": device_1_entity_id, + "color_temp": 236, + }, + 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 == call( + False, + 1, + 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, + 10, + dev1_cluster_color.commands_by_name["move_to_color_temp"].schema, + 236, # color temp mireds + 0, # transition time (ZCL time in 10ths of a second) - no transition when color_provided_while_off + 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"] == 25 + assert light1_state.attributes["color_temp"] == 236 + assert light1_state.attributes["color_mode"] == ColorMode.COLOR_TEMP + + dev1_cluster_on_off.request.reset_mock() + dev1_cluster_color.request.reset_mock() + + # turn light 1 back off to setup group test + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_off", + { + "entity_id": device_1_entity_id, + }, + 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 == 0 + assert dev1_cluster_color.request.await_count == 0 + assert dev1_cluster_level.request.call_count == 0 + assert dev1_cluster_level.request.await_count == 0 + group_state = hass.states.get(group_entity_id) + assert group_state.state == STATE_OFF + + dev1_cluster_on_off.request.reset_mock() + dev1_cluster_color.request.reset_mock() + dev1_cluster_level.request.reset_mock() + + # test sengled light uses default minimum transition time + dev2_cluster_on_off.request.reset_mock() + dev2_cluster_color.request.reset_mock() + dev2_cluster_level.request.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {"entity_id": device_2_entity_id, "transition": 0, "brightness": 100}, + blocking=True, + ) + assert dev2_cluster_on_off.request.call_count == 0 + assert dev2_cluster_on_off.request.await_count == 0 + assert dev2_cluster_color.request.call_count == 0 + assert dev2_cluster_color.request.await_count == 0 + assert dev2_cluster_level.request.call_count == 1 + assert dev2_cluster_level.request.await_count == 1 + assert dev2_cluster_level.request.call_args == call( + False, + 4, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 100, # brightness (level in ZCL) + 1, # transition time - sengled light uses default minimum + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + light2_state = hass.states.get(device_2_entity_id) + assert light2_state.state == STATE_ON + assert light2_state.attributes["brightness"] == 100 + + dev2_cluster_level.request.reset_mock() + + # turn the sengled light back off + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_off", + { + "entity_id": device_2_entity_id, + }, + blocking=True, + ) + assert dev2_cluster_on_off.request.call_count == 1 + assert dev2_cluster_on_off.request.await_count == 1 + assert dev2_cluster_color.request.call_count == 0 + assert dev2_cluster_color.request.await_count == 0 + assert dev2_cluster_level.request.call_count == 0 + assert dev2_cluster_level.request.await_count == 0 + light2_state = hass.states.get(device_2_entity_id) + assert light2_state.state == STATE_OFF + + dev2_cluster_on_off.request.reset_mock() + + # test non 0 length transition and color temp while turning light on and sengled (color_provided_while_off) + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + { + "entity_id": device_2_entity_id, + "transition": 1, + "brightness": 25, + "color_temp": 235, + }, + blocking=True, + ) + assert dev2_cluster_on_off.request.call_count == 0 + assert dev2_cluster_on_off.request.await_count == 0 + assert dev2_cluster_color.request.call_count == 1 + assert dev2_cluster_color.request.await_count == 1 + assert dev2_cluster_level.request.call_count == 2 + assert dev2_cluster_level.request.await_count == 2 + + # first it comes on with no transition at 2 brightness + assert dev2_cluster_level.request.call_args_list[0] == call( + False, + 4, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 2, # brightness (level in ZCL) + 1, # transition time (ZCL time in 10ths of a second) + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + assert dev2_cluster_color.request.call_args == call( + False, + 10, + dev2_cluster_color.commands_by_name["move_to_color_temp"].schema, + 235, # color temp mireds + 1, # transition time (ZCL time in 10ths of a second) - sengled transition == 1 when color_provided_while_off + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + assert dev2_cluster_level.request.call_args_list[1] == call( + False, + 0, + dev2_cluster_level.commands_by_name["move_to_level"].schema, + 25, # brightness (level in ZCL) + 10.0, # transition time (ZCL time in 10ths of a second) + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + light2_state = hass.states.get(device_2_entity_id) + assert light2_state.state == STATE_ON + assert light2_state.attributes["brightness"] == 25 + assert light2_state.attributes["color_temp"] == 235 + assert light2_state.attributes["color_mode"] == ColorMode.COLOR_TEMP + + dev2_cluster_level.request.reset_mock() + dev2_cluster_color.request.reset_mock() + + # turn the sengled light back off + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_off", + { + "entity_id": device_2_entity_id, + }, + blocking=True, + ) + assert dev2_cluster_on_off.request.call_count == 1 + assert dev2_cluster_on_off.request.await_count == 1 + assert dev2_cluster_color.request.call_count == 0 + assert dev2_cluster_color.request.await_count == 0 + assert dev2_cluster_level.request.call_count == 0 + assert dev2_cluster_level.request.await_count == 0 + light2_state = hass.states.get(device_2_entity_id) + assert light2_state.state == STATE_OFF + + dev2_cluster_on_off.request.reset_mock() + + # test non 0 length transition and color temp while turning group light on (color_provided_while_off) + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + { + "entity_id": group_entity_id, + "transition": 1, + "brightness": 25, + "color_temp": 235, + }, + blocking=True, + ) + + group_on_off_channel = zha_group.endpoint[general.OnOff.cluster_id] + group_level_channel = zha_group.endpoint[general.LevelControl.cluster_id] + group_color_channel = zha_group.endpoint[lighting.Color.cluster_id] + assert group_on_off_channel.request.call_count == 0 + assert group_on_off_channel.request.await_count == 0 + assert group_color_channel.request.call_count == 1 + assert group_color_channel.request.await_count == 1 + assert group_level_channel.request.call_count == 1 + assert group_level_channel.request.await_count == 1 + + # groups are omitted from the 3 call dance for color_provided_while_off + assert group_color_channel.request.call_args == call( + False, + 10, + dev2_cluster_color.commands_by_name["move_to_color_temp"].schema, + 235, # color temp mireds + 10.0, # transition time (ZCL time in 10ths of a second) - sengled transition == 1 when color_provided_while_off + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + assert group_level_channel.request.call_args == call( + False, + 4, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 25, # brightness (level in ZCL) + 10.0, # transition time (ZCL time in 10ths of a second) + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + group_state = hass.states.get(group_entity_id) + assert group_state.state == STATE_ON + assert group_state.attributes["brightness"] == 25 + assert group_state.attributes["color_temp"] == 235 + assert group_state.attributes["color_mode"] == ColorMode.COLOR_TEMP + + group_on_off_channel.request.reset_mock() + group_color_channel.request.reset_mock() + group_level_channel.request.reset_mock() + + # turn the sengled light back on + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + { + "entity_id": device_2_entity_id, + }, + blocking=True, + ) + assert dev2_cluster_on_off.request.call_count == 1 + assert dev2_cluster_on_off.request.await_count == 1 + assert dev2_cluster_color.request.call_count == 0 + assert dev2_cluster_color.request.await_count == 0 + assert dev2_cluster_level.request.call_count == 0 + assert dev2_cluster_level.request.await_count == 0 + light2_state = hass.states.get(device_2_entity_id) + assert light2_state.state == STATE_ON + + dev2_cluster_on_off.request.reset_mock() + + # turn the light off with a transition + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_off", + {"entity_id": device_2_entity_id, "transition": 2}, + blocking=True, + ) + assert dev2_cluster_on_off.request.call_count == 0 + assert dev2_cluster_on_off.request.await_count == 0 + assert dev2_cluster_color.request.call_count == 0 + assert dev2_cluster_color.request.await_count == 0 + assert dev2_cluster_level.request.call_count == 1 + assert dev2_cluster_level.request.await_count == 1 + assert dev2_cluster_level.request.call_args == call( + False, + 4, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 0, # brightness (level in ZCL) + 20, # transition time + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + light2_state = hass.states.get(device_2_entity_id) + assert light2_state.state == STATE_OFF + + dev2_cluster_level.request.reset_mock() + + # turn the light back on with no args should use a transition and last known brightness + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {"entity_id": device_2_entity_id}, + blocking=True, + ) + assert dev2_cluster_on_off.request.call_count == 0 + assert dev2_cluster_on_off.request.await_count == 0 + assert dev2_cluster_color.request.call_count == 0 + assert dev2_cluster_color.request.await_count == 0 + assert dev2_cluster_level.request.call_count == 1 + assert dev2_cluster_level.request.await_count == 1 + assert dev2_cluster_level.request.call_args == call( + False, + 4, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + 25, # brightness (level in ZCL) - this is the last brightness we set a few tests above + 1, # transition time - sengled light uses default minimum + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + light2_state = hass.states.get(device_2_entity_id) + assert light2_state.state == STATE_ON + + dev2_cluster_level.request.reset_mock() + + # test eWeLink color temp while turning light on from off (color_provided_while_off) + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + { + "entity_id": eWeLink_light_entity_id, + "color_temp": 235, + }, + blocking=True, + ) + assert eWeLink_cluster_on_off.request.call_count == 1 + assert eWeLink_cluster_on_off.request.await_count == 1 + assert eWeLink_cluster_color.request.call_count == 1 + assert eWeLink_cluster_color.request.await_count == 1 + assert eWeLink_cluster_level.request.call_count == 0 + assert eWeLink_cluster_level.request.await_count == 0 + + # first it comes on + assert eWeLink_cluster_on_off.request.call_args_list[0] == call( + False, + 1, + eWeLink_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, + 10, + dev1_cluster_color.commands_by_name["move_to_color_temp"].schema, + 235, # color temp mireds + 0, # transition time (ZCL time in 10ths of a second) + expect_reply=True, + manufacturer=None, + tries=1, + tsn=None, + ) + + eWeLink_state = hass.states.get(eWeLink_light_entity_id) + assert eWeLink_state.state == STATE_ON + assert eWeLink_state.attributes["color_temp"] == 235 + assert eWeLink_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 @@ -463,7 +1260,7 @@ async def async_test_level_on_off_from_hass( 4, level_cluster.commands_by_name["move_to_level_with_on_off"].schema, 10, - 1, + 0, expect_reply=True, manufacturer=None, tries=1, @@ -601,7 +1398,10 @@ async def test_zha_group_light_entity( # test that the lights were created and are off group_state = hass.states.get(group_entity_id) assert group_state.state == STATE_OFF - assert group_state.attributes["supported_color_modes"] == [ColorMode.HS] + assert group_state.attributes["supported_color_modes"] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ] # Light which is off has no color mode assert "color_mode" not in group_state.attributes @@ -629,7 +1429,10 @@ async def test_zha_group_light_entity( # 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["supported_color_modes"] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ] assert group_state.attributes["color_mode"] == ColorMode.HS # test long flashing the lights from the HA