Fix ZHA light brightness jumping around during transitions (#74849)

* Moved color commands to a new ``async_handle_color_commands`` method

* Fixed tests

* Fix brightness jumping around during transitions

* Add config option to disable "Enhanced brightness slider during light transition"
This commit is contained in:
TheJulianJES 2022-07-27 02:03:17 +02:00 committed by GitHub
parent 6868865e3e
commit 129b42cd23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 320 additions and 66 deletions

View File

@ -129,6 +129,7 @@ CONF_DATABASE = "database_path"
CONF_DEFAULT_LIGHT_TRANSITION = "default_light_transition"
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_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join"
CONF_ENABLE_QUIRKS = "enable_quirks"
@ -146,6 +147,7 @@ CONF_ZHA_OPTIONS_SCHEMA = vol.Schema(
{
vol.Optional(CONF_DEFAULT_LIGHT_TRANSITION): cv.positive_int,
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_ENABLE_IDENTIFY_ON_JOIN, default=True): cv.boolean,
vol.Optional(

View File

@ -1,7 +1,9 @@
"""Lights on Zigbee Home Automation networks."""
from __future__ import annotations
import asyncio
from collections import Counter
from collections.abc import Callable
from datetime import timedelta
import functools
import itertools
@ -26,14 +28,14 @@ from homeassistant.const import (
STATE_UNAVAILABLE,
Platform,
)
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.event import async_call_later, async_track_time_interval
from .core import discovery, helpers
from .core.const import (
@ -43,6 +45,7 @@ from .core.const import (
CONF_ALWAYS_PREFER_XY_COLOR_MODE,
CONF_DEFAULT_LIGHT_TRANSITION,
CONF_ENABLE_ENHANCED_LIGHT_TRANSITION,
CONF_ENABLE_LIGHT_TRANSITIONING_FLAG,
DATA_ZHA,
EFFECT_BLINK,
EFFECT_BREATHE,
@ -61,6 +64,10 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
DEFAULT_ON_OFF_TRANSITION = 1 # most bulbs default to a 1-second turn on/off transition
DEFAULT_EXTRA_TRANSITION_DELAY_SHORT = 0.25
DEFAULT_EXTRA_TRANSITION_DELAY_LONG = 2.0
DEFAULT_LONG_TRANSITION_TIME = 10
DEFAULT_MIN_BRIGHTNESS = 2
UPDATE_COLORLOOP_ACTION = 0x1
@ -75,6 +82,8 @@ STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.LIGHT)
GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.LIGHT)
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"
DEFAULT_MIN_TRANSITION_MANUFACTURERS = {"Sengled"}
COLOR_MODES_GROUP_LIGHT = {ColorMode.COLOR_TEMP, ColorMode.XY}
@ -111,6 +120,7 @@ class BaseLight(LogMixin, light.LightEntity):
def __init__(self, *args, **kwargs):
"""Initialize the light."""
self._zha_device: ZHADevice = None
super().__init__(*args, **kwargs)
self._attr_min_mireds: int | None = 153
self._attr_max_mireds: int | None = 500
@ -121,11 +131,14 @@ class BaseLight(LogMixin, light.LightEntity):
self._off_brightness: int | None = None
self._zha_config_transition = self._DEFAULT_MIN_TRANSITION_TIME
self._zha_config_enhanced_light_transition: bool = False
self._zha_config_enable_light_transitioning_flag: bool = True
self._zha_config_always_prefer_xy_color_mode: bool = True
self._on_off_channel = None
self._level_channel = None
self._color_channel = None
self._identify_channel = None
self._transitioning: bool = False
self._transition_listener: Callable[[], None] | None = None
@property
def extra_state_attributes(self) -> dict[str, Any]:
@ -151,6 +164,12 @@ class BaseLight(LogMixin, light.LightEntity):
on at `on_level` Zigbee attribute value, regardless of the last set
level
"""
if self._transitioning:
self.debug(
"received level %s while transitioning - skipping update",
value,
)
return
value = max(0, min(254, value))
self._attr_brightness = value
self.async_write_ha_state()
@ -170,6 +189,36 @@ class BaseLight(LogMixin, light.LightEntity):
xy_color = kwargs.get(light.ATTR_XY_COLOR)
hs_color = kwargs.get(light.ATTR_HS_COLOR)
set_transition_flag = (
brightness_supported(self._attr_supported_color_modes)
or temperature is not None
or xy_color is not None
or hs_color is not None
) and self._zha_config_enable_light_transitioning_flag
transition_time = (
(
duration / 10 + DEFAULT_EXTRA_TRANSITION_DELAY_SHORT
if (
(brightness is not None or transition is not None)
and brightness_supported(self._attr_supported_color_modes)
or (self._off_with_transition and self._off_brightness is not None)
or temperature is not None
or xy_color is not None
or hs_color is not None
)
else DEFAULT_ON_OFF_TRANSITION + DEFAULT_EXTRA_TRANSITION_DELAY_SHORT
)
if set_transition_flag
else 0
)
# If we need to pause attribute report parsing, we'll do so here.
# After successful calls, we later start a timer to unset the flag after transition_time.
# On an error on the first move to level call, we unset the flag immediately if no previous timer is running.
# On an error on subsequent calls, we start the transition timer, as a brightness call might have come through.
if set_transition_flag:
self.async_transition_set_flag()
# If the light is currently off but a turn_on call with a color/temperature is sent,
# the light needs to be turned on first at a low brightness level where the light is immediately transitioned
# to the correct color. Afterwards, the transition is only from the low brightness to the new brightness.
@ -230,6 +279,10 @@ class BaseLight(LogMixin, light.LightEntity):
)
t_log["move_to_level_with_on_off"] = result
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
# First 'move to level' call failed, so if the transitioning delay isn't running from a previous call,
# the flag can be unset immediately
if set_transition_flag and not self._transition_listener:
self.async_transition_complete()
self.debug("turned on: %s", t_log)
return
# Currently only setting it to "on", as the correct level state will be set at the second move_to_level call
@ -245,6 +298,10 @@ class BaseLight(LogMixin, light.LightEntity):
)
t_log["move_to_level_with_on_off"] = result
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
# First 'move to level' call failed, so if the transitioning delay isn't running from a previous call,
# the flag can be unset immediately
if set_transition_flag and not self._transition_listener:
self.async_transition_complete()
self.debug("turned on: %s", t_log)
return
self._attr_state = bool(level)
@ -261,73 +318,25 @@ class BaseLight(LogMixin, light.LightEntity):
result = await self._on_off_channel.on()
t_log["on_off"] = result
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
# 'On' call failed, but as brightness may still transition (for FORCE_ON lights),
# we start the timer to unset the flag after the transition_time if necessary.
self.async_transition_start_timer(transition_time)
self.debug("turned on: %s", t_log)
return
self._attr_state = True
if temperature is not None:
result = await self._color_channel.move_to_color_temp(
temperature,
self._DEFAULT_MIN_TRANSITION_TIME
if new_color_provided_while_off
else duration,
)
t_log["move_to_color_temp"] = result
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
self.debug("turned on: %s", t_log)
return
self._attr_color_mode = ColorMode.COLOR_TEMP
self._attr_color_temp = temperature
self._attr_xy_color = None
self._attr_hs_color = None
if hs_color is not None:
if (
not isinstance(self, LightGroup)
and self._color_channel.enhanced_hue_supported
):
result = await self._color_channel.enhanced_move_to_hue_and_saturation(
int(hs_color[0] * 65535 / 360),
int(hs_color[1] * 2.54),
self._DEFAULT_MIN_TRANSITION_TIME
if new_color_provided_while_off
else duration,
)
t_log["enhanced_move_to_hue_and_saturation"] = result
else:
result = await self._color_channel.move_to_hue_and_saturation(
int(hs_color[0] * 254 / 360),
int(hs_color[1] * 2.54),
self._DEFAULT_MIN_TRANSITION_TIME
if new_color_provided_while_off
else duration,
)
t_log["move_to_hue_and_saturation"] = result
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
self.debug("turned on: %s", t_log)
return
self._attr_color_mode = ColorMode.HS
self._attr_hs_color = hs_color
self._attr_xy_color = None
self._attr_color_temp = None
xy_color = None # don't set xy_color if it is also present
if xy_color is not None:
result = await self._color_channel.move_to_color(
int(xy_color[0] * 65535),
int(xy_color[1] * 65535),
self._DEFAULT_MIN_TRANSITION_TIME
if new_color_provided_while_off
else duration,
)
t_log["move_to_color"] = result
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
self.debug("turned on: %s", t_log)
return
self._attr_color_mode = ColorMode.XY
self._attr_xy_color = xy_color
self._attr_color_temp = None
self._attr_hs_color = None
if not await self.async_handle_color_commands(
temperature,
duration,
hs_color,
xy_color,
new_color_provided_while_off,
t_log,
):
# Color calls failed, but as brightness may still transition, we start the timer to unset the flag
self.async_transition_start_timer(transition_time)
self.debug("turned on: %s", t_log)
return
if new_color_provided_while_off:
# The light is has the correct color, so we can now transition it to the correct brightness level.
@ -340,6 +349,10 @@ class BaseLight(LogMixin, light.LightEntity):
if level:
self._attr_brightness = level
# Our light is guaranteed to have just started the transitioning process if necessary,
# so we start the delay for the transition (to stop parsing attribute reports after the completed transition).
self.async_transition_start_timer(transition_time)
if effect == light.EFFECT_COLORLOOP:
result = await self._color_channel.color_loop_set(
UPDATE_COLORLOOP_ACTION
@ -382,6 +395,15 @@ class BaseLight(LogMixin, light.LightEntity):
transition = kwargs.get(light.ATTR_TRANSITION)
supports_level = brightness_supported(self._attr_supported_color_modes)
transition_time = (
transition or self._DEFAULT_MIN_TRANSITION_TIME
if transition is not None
else DEFAULT_ON_OFF_TRANSITION
) + DEFAULT_EXTRA_TRANSITION_DELAY_SHORT
# Start pausing attribute report parsing
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
if transition is not None and supports_level:
result = await self._level_channel.move_to_level_with_on_off(
@ -389,6 +411,10 @@ class BaseLight(LogMixin, light.LightEntity):
)
else:
result = await self._on_off_channel.off()
# Pause parsing attribute reports until transition is complete
if self._zha_config_enable_light_transitioning_flag:
self.async_transition_start_timer(transition_time)
self.debug("turned off: %s", result)
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
return
@ -401,6 +427,127 @@ class BaseLight(LogMixin, light.LightEntity):
self.async_write_ha_state()
async def async_handle_color_commands(
self,
temperature,
duration,
hs_color,
xy_color,
new_color_provided_while_off,
t_log,
):
"""Process ZCL color commands."""
if temperature is not None:
result = await self._color_channel.move_to_color_temp(
temperature,
self._DEFAULT_MIN_TRANSITION_TIME
if new_color_provided_while_off
else duration,
)
t_log["move_to_color_temp"] = result
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
return False
self._attr_color_mode = ColorMode.COLOR_TEMP
self._attr_color_temp = temperature
self._attr_xy_color = None
self._attr_hs_color = None
if hs_color is not None:
if (
not isinstance(self, LightGroup)
and self._color_channel.enhanced_hue_supported
):
result = await self._color_channel.enhanced_move_to_hue_and_saturation(
int(hs_color[0] * 65535 / 360),
int(hs_color[1] * 2.54),
self._DEFAULT_MIN_TRANSITION_TIME
if new_color_provided_while_off
else duration,
)
t_log["enhanced_move_to_hue_and_saturation"] = result
else:
result = await self._color_channel.move_to_hue_and_saturation(
int(hs_color[0] * 254 / 360),
int(hs_color[1] * 2.54),
self._DEFAULT_MIN_TRANSITION_TIME
if new_color_provided_while_off
else duration,
)
t_log["move_to_hue_and_saturation"] = result
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
return False
self._attr_color_mode = ColorMode.HS
self._attr_hs_color = hs_color
self._attr_xy_color = None
self._attr_color_temp = None
xy_color = None # don't set xy_color if it is also present
if xy_color is not None:
result = await self._color_channel.move_to_color(
int(xy_color[0] * 65535),
int(xy_color[1] * 65535),
self._DEFAULT_MIN_TRANSITION_TIME
if new_color_provided_while_off
else duration,
)
t_log["move_to_color"] = result
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
return False
self._attr_color_mode = ColorMode.XY
self._attr_xy_color = xy_color
self._attr_color_temp = None
self._attr_hs_color = None
return True
@callback
def async_transition_set_flag(self) -> None:
"""Set _transitioning to True."""
self.debug("setting transitioning flag to True")
self._transitioning = True
if isinstance(self, LightGroup):
async_dispatcher_send(
self.hass,
SIGNAL_LIGHT_GROUP_TRANSITION_START,
{"entity_ids": self._entity_ids},
)
if self._transition_listener is not None:
self._transition_listener()
@callback
def async_transition_start_timer(self, transition_time) -> None:
"""Start a timer to unset _transitioning after transition_time if necessary."""
if not transition_time:
return
# For longer transitions, we want to extend the timer a bit more
if transition_time >= DEFAULT_LONG_TRANSITION_TIME:
transition_time += DEFAULT_EXTRA_TRANSITION_DELAY_LONG
self.debug("starting transitioning timer for %s", transition_time)
self._transition_listener = async_call_later(
self._zha_device.hass,
transition_time,
self.async_transition_complete,
)
@callback
def async_transition_complete(self, _=None) -> None:
"""Set _transitioning to False and write HA state."""
self.debug("transition complete - future attribute reports will write HA state")
self._transitioning = False
if self._transition_listener:
self._transition_listener()
self._transition_listener = None
self.async_write_ha_state()
if isinstance(self, LightGroup):
async_dispatcher_send(
self.hass,
SIGNAL_LIGHT_GROUP_TRANSITION_FINISHED,
{"entity_ids": self._entity_ids},
)
if self._debounced_member_refresh is not None:
self.debug("transition complete - refreshing group member states")
asyncio.create_task(self._debounced_member_refresh.async_call())
@STRICT_MATCH(channel_names=CHANNEL_ON_OFF, aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL})
class Light(BaseLight, ZhaEntity):
@ -506,10 +653,22 @@ class Light(BaseLight, ZhaEntity):
CONF_ENABLE_ENHANCED_LIGHT_TRANSITION,
False,
)
self._zha_config_enable_light_transitioning_flag = async_get_zha_config_value(
zha_device.gateway.config_entry,
ZHA_OPTIONS,
CONF_ENABLE_LIGHT_TRANSITIONING_FLAG,
True,
)
@callback
def async_set_state(self, attr_id, attr_name, value):
"""Set the state."""
if self._transitioning:
self.debug(
"received onoff %s while transitioning - skipping update",
value,
)
return
self._attr_state = bool(value)
if value:
self._off_with_transition = False
@ -537,6 +696,38 @@ class Light(BaseLight, ZhaEntity):
signal_override=True,
)
@callback
def transition_on(signal):
"""Handle a transition start event from a group."""
if self.entity_id in signal["entity_ids"]:
self.debug(
"group transition started - setting member transitioning flag"
)
self._transitioning = True
self.async_accept_signal(
None,
SIGNAL_LIGHT_GROUP_TRANSITION_START,
transition_on,
signal_override=True,
)
@callback
def transition_off(signal):
"""Handle a transition finished event from a group."""
if self.entity_id in signal["entity_ids"]:
self.debug(
"group transition completed - unsetting member transitioning flag"
)
self._transitioning = False
self.async_accept_signal(
None,
SIGNAL_LIGHT_GROUP_TRANSITION_FINISHED,
transition_off,
signal_override=True,
)
async def async_will_remove_from_hass(self) -> None:
"""Disconnect entity object when removed."""
assert self._cancel_refresh_handle
@ -654,16 +845,25 @@ class Light(BaseLight, ZhaEntity):
async def async_update(self):
"""Update to the latest state."""
if self._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:
self.debug("skipping _refresh while transitioning")
return
await self.async_get_state()
self.async_write_ha_state()
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:
self.debug("skipping _maybe_force_refresh while transitioning")
return
await self.async_get_state()
self.async_write_ha_state()
@ -726,6 +926,12 @@ class LightGroup(BaseLight, ZhaGroupEntity):
CONF_DEFAULT_LIGHT_TRANSITION,
0,
)
self._zha_config_enable_light_transitioning_flag = async_get_zha_config_value(
zha_device.gateway.config_entry,
ZHA_OPTIONS,
CONF_ENABLE_LIGHT_TRANSITIONING_FLAG,
True,
)
self._zha_config_always_prefer_xy_color_mode = async_get_zha_config_value(
zha_device.gateway.config_entry,
ZHA_OPTIONS,
@ -757,13 +963,32 @@ class LightGroup(BaseLight, ZhaGroupEntity):
async def async_turn_on(self, **kwargs):
"""Turn the entity on."""
await super().async_turn_on(**kwargs)
if self._transitioning:
return
await self._debounced_member_refresh.async_call()
async def async_turn_off(self, **kwargs):
"""Turn the entity off."""
await super().async_turn_off(**kwargs)
if self._transitioning:
return
await self._debounced_member_refresh.async_call()
@callback
def async_state_changed_listener(self, event: Event):
"""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."""
all_states = [self.hass.states.get(x) for x in self._entity_ids]

View File

@ -38,6 +38,7 @@
"zha_options": {
"title": "Global Options",
"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",
"enable_identify_on_join": "Enable identify effect when devices join the network",
"default_light_transition": "Default light transition time (seconds)",

View File

@ -46,12 +46,13 @@
"title": "Alarm Control Panel Options"
},
"zha_options": {
"always_prefer_xy_color_mode": "Always prefer XY color mode",
"consider_unavailable_battery": "Consider battery powered devices unavailable after (seconds)",
"consider_unavailable_mains": "Consider mains powered devices unavailable after (seconds)",
"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",
"always_prefer_xy_color_mode": "Always prefer XY color mode",
"light_transitioning_flag": "Enable enhanced brightness slider during light transition",
"title": "Global Options"
}
},

View File

@ -1,5 +1,6 @@
"""Common test objects."""
import asyncio
from datetime import timedelta
import math
from unittest.mock import AsyncMock, Mock
@ -8,6 +9,9 @@ import zigpy.zcl.foundation as zcl_f
import homeassistant.components.zha.core.const as zha_const
from homeassistant.helpers import entity_registry
import homeassistant.util.dt as dt_util
from tests.common import async_fire_time_changed
def patch_cluster(cluster):
@ -232,3 +236,10 @@ async def async_wait_for_updates(hass):
await asyncio.sleep(0)
await asyncio.sleep(0)
await hass.async_block_till_done()
async def async_shift_time(hass):
"""Shift time to cause call later tasks to run."""
next_update = dt_util.utcnow() + timedelta(seconds=11)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()

View File

@ -22,6 +22,7 @@ import homeassistant.util.dt as dt_util
from .common import (
async_enable_traffic,
async_find_group_entity_id,
async_shift_time,
async_test_rejoin,
find_entity_id,
get_zha_gateway,
@ -346,6 +347,7 @@ async def test_light(
await async_test_level_on_off_from_hass(
hass, cluster_on_off, cluster_level, entity_id
)
await async_shift_time(hass)
# test getting a brightness change from the network
await async_test_on_from_light(hass, cluster_on_off, entity_id)
@ -1190,6 +1192,8 @@ async def async_test_level_on_off_from_hass(
on_off_cluster.request.reset_mock()
level_cluster.request.reset_mock()
await async_shift_time(hass)
# turn on via UI
await hass.services.async_call(
LIGHT_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True
@ -1210,6 +1214,8 @@ async def async_test_level_on_off_from_hass(
on_off_cluster.request.reset_mock()
level_cluster.request.reset_mock()
await async_shift_time(hass)
await hass.services.async_call(
LIGHT_DOMAIN,
"turn_on",
@ -1407,11 +1413,15 @@ async def test_zha_group_light_entity(
# test turning the lights on and off from the HA
await async_test_on_off_from_hass(hass, group_cluster_on_off, group_entity_id)
await async_shift_time(hass)
# test short flashing the lights from the HA
await async_test_flash_from_hass(
hass, group_cluster_identify, group_entity_id, FLASH_SHORT
)
await async_shift_time(hass)
# test turning the lights on and off from the light
await async_test_on_off_from_light(hass, dev1_cluster_on_off, group_entity_id)
@ -1424,6 +1434,8 @@ async def test_zha_group_light_entity(
expected_default_transition=1, # a Sengled light is in that group and needs a minimum 0.1s transition
)
await async_shift_time(hass)
# test getting a brightness change from the network
await async_test_on_from_light(hass, dev1_cluster_on_off, group_entity_id)
await async_test_dimmer_from_light(
@ -1443,6 +1455,8 @@ async def test_zha_group_light_entity(
hass, group_cluster_identify, group_entity_id, FLASH_LONG
)
await async_shift_time(hass)
assert len(zha_group.members) == 2
# test some of the group logic to make sure we key off states correctly
await send_attributes_report(hass, dev1_cluster_on_off, {0: 1})