Implement "group members assume state" option for ZHA (#84938)

* Initial "group members assume state" implementation for ZHA

* Remove left-over debug flag (where polling was disabled)

* Implement _send_member_assume_state_event() method and also use after turn_off

* Only assume updated arguments from service call to group

* Make code more readable and change checks slightly

* Move "send member assume state" events to LightGroup on/off calls

* Include new config option in tests

* Check that member is available before updating to assumed state

* Lower "update group from child delay" for debouncer to basically 0 when using assumed member state

* Allow "child to group" updates regardless of config option

This is not needed, as group members will not update their state, as long as they're transitioning. (If a group transitions, it also sets its members to transitioning mode)

This fixes multiple issues. Previously, the state of a group was completely wrong when:
- turn on group with 10 second transition
- turn on members individually
- turn off members individually
- group state would not update correctly

* Move "default update group from child delay" constant

* Change to new constant name in test

* Also update fan test to new constant name

* Decrease "update group from child delay" to 10ms

In my testing, 0.0 also works without any issues and correctly de-bounces child updates when using the "assume state option".
This is just for avoiding multiple state changes when changing the group -> children issue individual updates.
With 2 children in a group and delay 0, both child updates only cause one group re-calculation and state change.

0.01 (10ms) should be plenty for very slow systems to de-bounce the update (and in the worst case, it'll cause just another state change but nothing breaks)

* Also implement "assuming state" for effect

Not sure if anybody even uses this, but this one is a bit special because the effect is always deactivated if it's not provided in the light.turn_on call.

* Move shortened delay for "assuming members" to a constant

* Add basic test to verify that group members assume on/off state

* Move _assume_group_state function declaration out of async_added_to_hass

* Fix rare edge-case when rapidly toggling lights and light groups at the same time

This prevents an issue where either the group transition would unset the transition flag or the single light would unset the group transition status midst-transition.

Note: When a new individual transition is started, we want to unset the group flag, as we actually cancel that transition.

* Check that effect list exists, add return type

* Re-trigger CI due to timeout

* Increase ASSUME_UPDATE_GROUP_FROM_CHILD_DELAY slightly

The debouncer is used when updating group member states either by assuming them (in which case we want to barely have any delay), or between the time we get the results back from polling (where we want a slightly longer time).
As it's not easily possible to distinguish if a group member was updated via assuming the state of the group or by the polling that follows, 50 ms seems to be a good middle point.

* Add debug print for when updating group state

* Fix issues with "off brightness" when switching between group/members

This fixes a bunch of issues with "off brightness" and passes it down to the members correctly.
For example, if a light group is turned off with a transition (so bulbs get their level set to 1), this will also set the "off brightness" of all individual bulbs to the last level that they were at.

(It really fixes a lot of issues when using the "member assume group state" option. It's not really possible to fix them without that.)

Furthermore, issues where polling was previously needed to get the correct state after "playing with transitions", should now get be resolved and get correct state when using the "members assume group state" option.

Note: The only case which still can't be fixed is the following:
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 (<- at least now shows correctly in UI even before polling).
Since all lights might need different brightness levels to be turned on, we can't use one group call. But making individual calls when turning on a ZHA group would cause a lot of traffic and thereby be counter-productive.
In this case, light.turn_on should just be called with a level (or individual calls to the lights should be made).

Another thing that was changed is to reset off_with_transition/off_brightness for a LightGroup when a member is turned on (even if the LightGroup wasn't turned on using its turn_on method).
off_with_transition/off_brightness for individual bulbs is now also turned off when a light is detected to be on during polling.

Lastly, the waiting for polled attributes could previously cause "invalid state" to be set (so mid-transition levels).
This could happen when group and members are repeatedly toggled at similar times. These "invalid states" could cause wrong "off brightness" levels if transitions are also used.
To fix this, we check after waiting for the polled attributes in async_get_state to see if a transition has started in the meanwhile. If so, the values can be discarded. A new poll will happen later and if using the "members assume group state" config option, the values should already be correct before the polling.

* Enable "group members assume state" config option by default

The config tests are also updated to expect the config option be enabled by default.

For all tests, the config option is generally disabled though:
There are only two group related tests. The one that tests this new feature overrides the config option to be enabled anyway.
The other tests works in a similar way but also "sends" attribute reports, so we want to disable the feature for that test.
(It would also run with it enabled (if the correct CHILD_UPDATE value is patched), but then it would test the same stuff as the other test, hence we're disabling the config option for that test.)
This commit is contained in:
TheJulianJES 2023-01-16 16:48:18 +01:00 committed by GitHub
parent c3e27f6812
commit 9f0bed0f0c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 308 additions and 40 deletions

View file

@ -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,

View file

@ -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),
)

View file

@ -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,
)

View file

@ -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)",

View file

@ -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"
}

View file

@ -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,

View file

@ -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,

View file

@ -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(

View file

@ -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

View file

@ -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(