diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index c6958abc742..8a773213a58 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -133,6 +133,7 @@ CONF_DEVICE_CONFIG = "device_config" CONF_ENABLE_ENHANCED_LIGHT_TRANSITION = "enhanced_light_transition" CONF_ENABLE_LIGHT_TRANSITIONING_FLAG = "light_transitioning_flag" CONF_ALWAYS_PREFER_XY_COLOR_MODE = "always_prefer_xy_color_mode" +CONF_GROUP_MEMBERS_ASSUME_STATE = "group_members_assume_state" CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" CONF_FLOWCONTROL = "flow_control" @@ -151,6 +152,7 @@ CONF_ZHA_OPTIONS_SCHEMA = vol.Schema( vol.Required(CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, default=False): cv.boolean, vol.Required(CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, default=True): cv.boolean, vol.Required(CONF_ALWAYS_PREFER_XY_COLOR_MODE, default=True): cv.boolean, + vol.Required(CONF_GROUP_MEMBERS_ASSUME_STATE, default=True): cv.boolean, vol.Required(CONF_ENABLE_IDENTIFY_ON_JOIN, default=True): cv.boolean, vol.Optional( CONF_CONSIDER_UNAVAILABLE_MAINS, diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 7b4046eca5d..065f2ce1572 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -41,7 +41,7 @@ _ZhaGroupEntitySelfT = TypeVar("_ZhaGroupEntitySelfT", bound="ZhaGroupEntity") _LOGGER = logging.getLogger(__name__) ENTITY_SUFFIX = "entity_suffix" -UPDATE_GROUP_FROM_CHILD_DELAY = 0.5 +DEFAULT_UPDATE_GROUP_FROM_CHILD_DELAY = 0.5 class BaseZhaEntity(LogMixin, entity.Entity): @@ -270,6 +270,7 @@ class ZhaGroupEntity(BaseZhaEntity): self._async_unsub_state_changed: CALLBACK_TYPE | None = None self._handled_group_membership = False self._change_listener_debouncer: Debouncer | None = None + self._update_group_from_child_delay = DEFAULT_UPDATE_GROUP_FROM_CHILD_DELAY @property def available(self) -> bool: @@ -316,7 +317,7 @@ class ZhaGroupEntity(BaseZhaEntity): self._change_listener_debouncer = Debouncer( self.hass, _LOGGER, - cooldown=UPDATE_GROUP_FROM_CHILD_DELAY, + cooldown=self._update_group_from_child_delay, immediate=False, function=functools.partial(self.async_update_ha_state, True), ) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 664c2e63ad2..4be2c910f22 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -29,7 +29,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, Platform, ) -from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, State, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -47,6 +47,7 @@ from .core.const import ( CONF_DEFAULT_LIGHT_TRANSITION, CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, + CONF_GROUP_MEMBERS_ASSUME_STATE, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -67,6 +68,7 @@ DEFAULT_EXTRA_TRANSITION_DELAY_SHORT = 0.25 DEFAULT_EXTRA_TRANSITION_DELAY_LONG = 2.0 DEFAULT_LONG_TRANSITION_TIME = 10 DEFAULT_MIN_BRIGHTNESS = 2 +ASSUME_UPDATE_GROUP_FROM_CHILD_DELAY = 0.05 FLASH_EFFECTS = { light.FLASH_SHORT: Identify.EffectIdentifier.Blink, @@ -79,6 +81,7 @@ PARALLEL_UPDATES = 0 SIGNAL_LIGHT_GROUP_STATE_CHANGED = "zha_light_group_state_changed" SIGNAL_LIGHT_GROUP_TRANSITION_START = "zha_light_group_transition_start" SIGNAL_LIGHT_GROUP_TRANSITION_FINISHED = "zha_light_group_transition_finished" +SIGNAL_LIGHT_GROUP_ASSUME_GROUP_STATE = "zha_light_group_assume_group_state" DEFAULT_MIN_TRANSITION_MANUFACTURERS = {"sengled"} COLOR_MODES_GROUP_LIGHT = {ColorMode.COLOR_TEMP, ColorMode.XY} @@ -132,7 +135,8 @@ class BaseLight(LogMixin, light.LightEntity): self._level_channel = None self._color_channel = None self._identify_channel = None - self._transitioning: bool = False + self._transitioning_individual: bool = False + self._transitioning_group: bool = False self._transition_listener: Callable[[], None] | None = None @property @@ -159,7 +163,7 @@ class BaseLight(LogMixin, light.LightEntity): on at `on_level` Zigbee attribute value, regardless of the last set level """ - if self._transitioning: + if self.is_transitioning: self.debug( "received level %s while transitioning - skipping update", value, @@ -407,7 +411,7 @@ class BaseLight(LogMixin, light.LightEntity): if self._zha_config_enable_light_transitioning_flag: self.async_transition_set_flag() - # is not none looks odd here but it will override built in bulb transition times if we pass 0 in here + # 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( level=0, @@ -424,10 +428,15 @@ class BaseLight(LogMixin, light.LightEntity): return self._attr_state = False - if supports_level: - # store current brightness so that the next turn_on uses it. - self._off_with_transition = transition is not None + if supports_level and not self._off_with_transition: + # store current brightness so that the next turn_on uses it: + # when using "enhanced turn on" self._off_brightness = self._attr_brightness + if transition is not None: + # save for when calling turn_on without a brightness: + # current_level is set to 1 after transitioning to level 0, needed for correct state with light groups + self._attr_brightness = 1 + self._off_with_transition = transition is not None self.async_write_ha_state() @@ -503,11 +512,17 @@ class BaseLight(LogMixin, light.LightEntity): return True + @property + def is_transitioning(self) -> bool: + """Return if the light is transitioning.""" + return self._transitioning_individual or self._transitioning_group + @callback def async_transition_set_flag(self) -> None: """Set _transitioning to True.""" self.debug("setting transitioning flag to True") - self._transitioning = True + self._transitioning_individual = True + self._transitioning_group = False if isinstance(self, LightGroup): async_dispatcher_send( self.hass, @@ -519,7 +534,7 @@ class BaseLight(LogMixin, light.LightEntity): @callback def async_transition_start_timer(self, transition_time) -> None: - """Start a timer to unset _transitioning after transition_time if necessary.""" + """Start a timer to unset _transitioning_individual after transition_time if necessary.""" if not transition_time: return # For longer transitions, we want to extend the timer a bit more @@ -534,9 +549,9 @@ class BaseLight(LogMixin, light.LightEntity): @callback def async_transition_complete(self, _=None) -> None: - """Set _transitioning to False and write HA state.""" + """Set _transitioning_individual to False and write HA state.""" self.debug("transition complete - future attribute reports will write HA state") - self._transitioning = False + self._transitioning_individual = False if self._transition_listener: self._transition_listener() self._transition_listener = None @@ -671,7 +686,7 @@ class Light(BaseLight, ZhaEntity): @callback def async_set_state(self, attr_id, attr_name, value): """Set the state.""" - if self._transitioning: + if self.is_transitioning: self.debug( "received onoff %s while transitioning - skipping update", value, @@ -711,7 +726,7 @@ class Light(BaseLight, ZhaEntity): self.debug( "group transition started - setting member transitioning flag" ) - self._transitioning = True + self._transitioning_group = True self.async_accept_signal( None, @@ -727,7 +742,7 @@ class Light(BaseLight, ZhaEntity): self.debug( "group transition completed - unsetting member transitioning flag" ) - self._transitioning = False + self._transitioning_group = False self.async_accept_signal( None, @@ -736,6 +751,13 @@ class Light(BaseLight, ZhaEntity): signal_override=True, ) + self.async_accept_signal( + None, + SIGNAL_LIGHT_GROUP_ASSUME_GROUP_STATE, + self._assume_group_state, + signal_override=True, + ) + async def async_will_remove_from_hass(self) -> None: """Disconnect entity object when removed.""" assert self._cancel_refresh_handle @@ -768,18 +790,31 @@ class Light(BaseLight, ZhaEntity): if not self._attr_available: return self.debug("polling current state") + if self._on_off_channel: state = await self._on_off_channel.get_attribute_value( "on_off", from_cache=False ) + # check if transition started whilst waiting for polled state + if self.is_transitioning: + return + if state is not None: self._attr_state = state + if state: # reset "off with transition" flag if the light is on + self._off_with_transition = False + self._off_brightness = None + if self._level_channel: level = await self._level_channel.get_attribute_value( "current_level", from_cache=False ) + # check if transition started whilst waiting for polled state + if self.is_transitioning: + return if level is not None: self._attr_brightness = level + if self._color_channel: attributes = [ "color_mode", @@ -808,6 +843,11 @@ class Light(BaseLight, ZhaEntity): attributes, from_cache=False, only_cache=False ) + # although rare, a transition might have been started while we were waiting for the polled attributes, + # so abort if we are transitioning, as that state will not be accurate + if self.is_transitioning: + return + if (color_mode := results.get("color_mode")) is not None: if color_mode == Color.ColorMode.Color_temperature: self._attr_color_mode = ColorMode.COLOR_TEMP @@ -853,14 +893,14 @@ class Light(BaseLight, ZhaEntity): async def async_update(self) -> None: """Update to the latest state.""" - if self._transitioning: + if self.is_transitioning: self.debug("skipping async_update while transitioning") return await self.async_get_state() async def _refresh(self, time): """Call async_get_state at an interval.""" - if self._transitioning: + if self.is_transitioning: self.debug("skipping _refresh while transitioning") return await self.async_get_state() @@ -869,12 +909,71 @@ class Light(BaseLight, ZhaEntity): async def _maybe_force_refresh(self, signal): """Force update the state if the signal contains the entity id for this entity.""" if self.entity_id in signal["entity_ids"]: - if self._transitioning: + if self.is_transitioning: self.debug("skipping _maybe_force_refresh while transitioning") return await self.async_get_state() self.async_write_ha_state() + @callback + def _assume_group_state(self, signal, update_params) -> None: + """Handle an assume group state event from a group.""" + if self.entity_id in signal["entity_ids"] and self._attr_available: + self.debug("member assuming group state with: %s", update_params) + + state = update_params["state"] + brightness = update_params.get(light.ATTR_BRIGHTNESS) + color_mode = update_params.get(light.ATTR_COLOR_MODE) + color_temp = update_params.get(light.ATTR_COLOR_TEMP) + xy_color = update_params.get(light.ATTR_XY_COLOR) + hs_color = update_params.get(light.ATTR_HS_COLOR) + effect = update_params.get(light.ATTR_EFFECT) + + supported_modes = self._attr_supported_color_modes + + # unset "off brightness" and "off with transition" if group turned on this light + if state and not self._attr_state: + self._off_with_transition = False + self._off_brightness = None + + # set "off brightness" and "off with transition" if group turned off this light + elif ( + not state # group is turning the light off + and self._attr_state # check the light was not already off (to not override _off_with_transition) + and brightness_supported(supported_modes) + ): + # use individual brightness, instead of possibly averaged brightness from group + self._off_brightness = self._attr_brightness + self._off_with_transition = update_params["off_with_transition"] + + # Note: If individual lights have off_with_transition set, but not the group, + # and the group is then turned on without a level, individual lights might fall back to brightness level 1. + # Since all lights might need different brightness levels to be turned on, we can't use one group call. + # And making individual calls when turning on a ZHA group would cause a lot of traffic. + # In this case, turn_on should either just be called with a level or individual turn_on calls can be used. + + # state is always set (light.turn_on/light.turn_off) + self._attr_state = state + + # before assuming a group state attribute, check if the attribute was actually set in that call + if brightness is not None and brightness_supported(supported_modes): + self._attr_brightness = brightness + if color_mode is not None and color_mode in supported_modes: + self._attr_color_mode = color_mode + if color_temp is not None and ColorMode.COLOR_TEMP in supported_modes: + self._attr_color_temp = color_temp + if xy_color is not None and ColorMode.XY in supported_modes: + self._attr_xy_color = xy_color + if hs_color is not None and ColorMode.HS in supported_modes: + self._attr_hs_color = hs_color + # the effect is always deactivated in async_turn_on if not provided + if effect is None: + self._attr_effect = None + elif self._attr_effect_list and effect in self._attr_effect_list: + self._attr_effect = effect + + self.async_write_ha_state() + @STRICT_MATCH( channel_names=CHANNEL_ON_OFF, @@ -951,6 +1050,14 @@ class LightGroup(BaseLight, ZhaGroupEntity): CONF_ALWAYS_PREFER_XY_COLOR_MODE, True, ) + self._zha_config_group_members_assume_state = async_get_zha_config_value( + zha_device.gateway.config_entry, + ZHA_OPTIONS, + CONF_GROUP_MEMBERS_ASSUME_STATE, + True, + ) + if self._zha_config_group_members_assume_state: + self._update_group_from_child_delay = ASSUME_UPDATE_GROUP_FROM_CHILD_DELAY self._zha_config_enhanced_light_transition = False self._attr_color_mode = None @@ -975,8 +1082,13 @@ class LightGroup(BaseLight, ZhaGroupEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" + # "off with transition" and "off brightness" will get overridden when turning on the group, + # but they are needed for setting the assumed member state correctly, so save them here + off_brightness = self._off_brightness if self._off_with_transition else None await super().async_turn_on(**kwargs) - if self._transitioning: + if self._zha_config_group_members_assume_state: + self._send_member_assume_state_event(True, kwargs, off_brightness) + if self.is_transitioning: # when transitioning, state is refreshed at the end return if self._debounced_member_refresh: await self._debounced_member_refresh.async_call() @@ -984,33 +1096,27 @@ class LightGroup(BaseLight, ZhaGroupEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await super().async_turn_off(**kwargs) - if self._transitioning: + if self._zha_config_group_members_assume_state: + self._send_member_assume_state_event(False, kwargs) + if self.is_transitioning: return if self._debounced_member_refresh: await self._debounced_member_refresh.async_call() - @callback - def async_state_changed_listener(self, event: Event) -> None: - """Handle child updates.""" - if self._transitioning: - self.debug("skipping group entity state update during transition") - return - super().async_state_changed_listener(event) - - async def async_update_ha_state(self, force_refresh: bool = False) -> None: - """Update Home Assistant with current state of entity.""" - if self._transitioning: - self.debug("skipping group entity state update during transition") - return - await super().async_update_ha_state(force_refresh) - async def async_update(self) -> None: """Query all members and determine the light group state.""" + self.debug("updating group state") all_states = [self.hass.states.get(x) for x in self._entity_ids] states: list[State] = list(filter(None, all_states)) on_states = [state for state in states if state.state == STATE_ON] self._attr_state = len(on_states) > 0 + + # reset "off with transition" flag if any member is on + if self._attr_state: + self._off_with_transition = False + self._off_brightness = None + self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) self._attr_brightness = helpers.reduce_attribute( @@ -1095,3 +1201,41 @@ class LightGroup(BaseLight, ZhaGroupEntity): SIGNAL_LIGHT_GROUP_STATE_CHANGED, {"entity_ids": self._entity_ids}, ) + + def _send_member_assume_state_event( + self, state, service_kwargs, off_brightness=None + ) -> None: + """Send an assume event to all members of the group.""" + update_params = { + "state": state, + "off_with_transition": self._off_with_transition, + } + + # check if the parameters were actually updated in the service call before updating members + if light.ATTR_BRIGHTNESS in service_kwargs: # or off brightness + update_params[light.ATTR_BRIGHTNESS] = self._attr_brightness + elif off_brightness is not None: + # if we turn on the group light with "off brightness", pass that to the members + update_params[light.ATTR_BRIGHTNESS] = off_brightness + + if light.ATTR_COLOR_TEMP in service_kwargs: + update_params[light.ATTR_COLOR_MODE] = self._attr_color_mode + update_params[light.ATTR_COLOR_TEMP] = self._attr_color_temp + + if light.ATTR_XY_COLOR in service_kwargs: + update_params[light.ATTR_COLOR_MODE] = self._attr_color_mode + update_params[light.ATTR_XY_COLOR] = self._attr_xy_color + + if light.ATTR_HS_COLOR in service_kwargs: + update_params[light.ATTR_COLOR_MODE] = self._attr_color_mode + update_params[light.ATTR_HS_COLOR] = self._attr_hs_color + + if light.ATTR_EFFECT in service_kwargs: + update_params[light.ATTR_EFFECT] = self._attr_effect + + async_dispatcher_send( + self.hass, + SIGNAL_LIGHT_GROUP_ASSUME_GROUP_STATE, + {"entity_ids": self._entity_ids}, + update_params, + ) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 057c0adb007..78a9755c744 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -163,6 +163,7 @@ "enhanced_light_transition": "Enable enhanced light color/temperature transition from an off-state", "light_transitioning_flag": "Enable enhanced brightness slider during light transition", "always_prefer_xy_color_mode": "Always prefer XY color mode", + "group_members_assume_state": "Group members assume state of group", "enable_identify_on_join": "Enable identify effect when devices join the network", "default_light_transition": "Default light transition time (seconds)", "consider_unavailable_mains": "Consider mains powered devices unavailable after (seconds)", diff --git a/homeassistant/components/zha/translations/en.json b/homeassistant/components/zha/translations/en.json index 049cdce4c4c..2d32330004b 100644 --- a/homeassistant/components/zha/translations/en.json +++ b/homeassistant/components/zha/translations/en.json @@ -87,6 +87,7 @@ "default_light_transition": "Default light transition time (seconds)", "enable_identify_on_join": "Enable identify effect when devices join the network", "enhanced_light_transition": "Enable enhanced light color/temperature transition from an off-state", + "group_members_assume_state": "Group members assume state of group", "light_transitioning_flag": "Enable enhanced brightness slider during light transition", "title": "Global Options" } diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 3cced05ad33..784e6bae731 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -84,6 +84,7 @@ async def config_entry_fixture(hass): zha_const.CUSTOM_CONFIGURATION: { zha_const.ZHA_OPTIONS: { zha_const.CONF_ENABLE_ENHANCED_LIGHT_TRANSITION: True, + zha_const.CONF_GROUP_MEMBERS_ASSUME_STATE: False, }, zha_const.ZHA_ALARM_OPTIONS: { zha_const.CONF_ALARM_ARM_REQUIRES_CODE: False, diff --git a/tests/components/zha/data.py b/tests/components/zha/data.py index 8b613ec2971..024a5e75fbc 100644 --- a/tests/components/zha/data.py +++ b/tests/components/zha/data.py @@ -28,6 +28,12 @@ BASE_CUSTOM_CONFIGURATION = { "required": True, "default": True, }, + { + "type": "boolean", + "name": "group_members_assume_state", + "required": True, + "default": True, + }, { "type": "boolean", "name": "enable_identify_on_join", @@ -56,6 +62,7 @@ BASE_CUSTOM_CONFIGURATION = { "default_light_transition": 0, "light_transitioning_flag": True, "always_prefer_xy_color_mode": True, + "group_members_assume_state": False, "enable_identify_on_join": True, "consider_unavailable_mains": 7200, "consider_unavailable_battery": 21600, @@ -91,6 +98,12 @@ CONFIG_WITH_ALARM_OPTIONS = { "required": True, "default": True, }, + { + "type": "boolean", + "name": "group_members_assume_state", + "required": True, + "default": True, + }, { "type": "boolean", "name": "enable_identify_on_join", @@ -140,6 +153,7 @@ CONFIG_WITH_ALARM_OPTIONS = { "default_light_transition": 0, "light_transitioning_flag": True, "always_prefer_xy_color_mode": True, + "group_members_assume_state": False, "enable_identify_on_join": True, "consider_unavailable_mains": 7200, "consider_unavailable_battery": 21600, diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 45e881fcfad..87f41e2acc2 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -271,7 +271,7 @@ async def async_set_preset_mode(hass, entity_id, preset_mode=None): new=AsyncMock(return_value=zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]), ) @patch( - "homeassistant.components.zha.entity.UPDATE_GROUP_FROM_CHILD_DELAY", + "homeassistant.components.zha.entity.DEFAULT_UPDATE_GROUP_FROM_CHILD_DELAY", new=0, ) async def test_zha_group_fan_entity(hass, device_fan_1, device_fan_2, coordinator): @@ -383,7 +383,7 @@ async def test_zha_group_fan_entity(hass, device_fan_1, device_fan_2, coordinato new=AsyncMock(side_effect=ZigbeeException), ) @patch( - "homeassistant.components.zha.entity.UPDATE_GROUP_FROM_CHILD_DELAY", + "homeassistant.components.zha.entity.DEFAULT_UPDATE_GROUP_FROM_CHILD_DELAY", new=0, ) async def test_zha_group_fan_entity_failure_state( diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 572a261d7e1..8d48b061ac4 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -16,6 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.components.zha.core.const import ( CONF_ALWAYS_PREFER_XY_COLOR_MODE, + CONF_GROUP_MEMBERS_ASSUME_STATE, ZHA_OPTIONS, ) from homeassistant.components.zha.core.group import GroupMember @@ -1410,7 +1411,7 @@ async def async_test_flash_from_hass(hass, cluster, entity_id, flash): new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), ) @patch( - "homeassistant.components.zha.entity.UPDATE_GROUP_FROM_CHILD_DELAY", + "homeassistant.components.zha.entity.DEFAULT_UPDATE_GROUP_FROM_CHILD_DELAY", new=0, ) async def test_zha_group_light_entity( @@ -1632,3 +1633,106 @@ async def test_zha_group_light_entity( await zha_gateway.async_remove_zigpy_group(zha_group.group_id) assert hass.states.get(group_entity_id) is None assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is None + + +@patch( + "zigpy.zcl.clusters.general.OnOff.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "homeassistant.components.zha.light.ASSUME_UPDATE_GROUP_FROM_CHILD_DELAY", + new=0, +) +async def test_group_member_assume_state( + hass, + zigpy_device_mock, + zha_device_joined, + coordinator, + device_light_1, + device_light_2, +): + """Test the group members assume state function.""" + with patch_zha_config( + "light", {(ZHA_OPTIONS, CONF_GROUP_MEMBERS_ASSUME_STATE): True} + ): + 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) + + 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 + + group_cluster_on_off = zha_group.endpoint[general.OnOff.cluster_id] + + await async_enable_traffic( + hass, [device_light_1, device_light_2], enabled=False + ) + await async_wait_for_updates(hass) + # test that the lights were created and that they are unavailable + assert hass.states.get(group_entity_id).state == STATE_UNAVAILABLE + + # 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 + + group_cluster_on_off.request.reset_mock() + await async_shift_time(hass) + + # turn on via UI + await hass.services.async_call( + LIGHT_DOMAIN, "turn_on", {"entity_id": group_entity_id}, blocking=True + ) + + # members also instantly assume STATE_ON + assert hass.states.get(device_1_entity_id).state == STATE_ON + assert hass.states.get(device_2_entity_id).state == STATE_ON + assert hass.states.get(group_entity_id).state == STATE_ON + + # turn off via UI + await hass.services.async_call( + LIGHT_DOMAIN, "turn_off", {"entity_id": group_entity_id}, blocking=True + ) + + # members also instantly assume STATE_OFF + assert hass.states.get(device_1_entity_id).state == STATE_OFF + assert hass.states.get(device_2_entity_id).state == STATE_OFF + assert hass.states.get(group_entity_id).state == STATE_OFF + + # remove the group and ensure that there is no entity and that the entity registry is cleaned up + assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is not None + await zha_gateway.async_remove_zigpy_group(zha_group.group_id) + assert hass.states.get(group_entity_id) is None + assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is None diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index a1f63b9a039..f274abdea50 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -257,7 +257,7 @@ async def zigpy_device_tuya(hass, zigpy_device_mock, zha_device_joined): @patch( - "homeassistant.components.zha.entity.UPDATE_GROUP_FROM_CHILD_DELAY", + "homeassistant.components.zha.entity.DEFAULT_UPDATE_GROUP_FROM_CHILD_DELAY", new=0, ) async def test_zha_group_switch_entity(