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:
parent
c3e27f6812
commit
9f0bed0f0c
10 changed files with 308 additions and 40 deletions
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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)",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Add table
Reference in a new issue