Skip to content

Commit 782f9f6

Browse files
authored
Debounce group entity update when member state changes (#74)
* Debounce group entity update when member state changes * handle timing difference when assume group state is True for lights * cleanup * wrap with callback
1 parent 155b877 commit 782f9f6

File tree

9 files changed

+143
-23
lines changed

9 files changed

+143
-23
lines changed

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ def zha_data() -> ZHAData:
295295
),
296296
light_options=LightOptions(
297297
enable_enhanced_light_transition=True,
298-
group_members_assume_state=False,
298+
group_members_assume_state=True,
299299
),
300300
alarm_control_panel_options=AlarmControlPanelOptions(
301301
arm_requires_code=False,

tests/test_fan.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
# pylint: disable=redefined-outer-name
44

5+
import asyncio
56
from collections.abc import Awaitable, Callable
67
import logging
78
from typing import Optional
@@ -255,6 +256,7 @@ async def async_set_preset_mode(
255256
"zigpy.zcl.clusters.hvac.Fan.write_attributes",
256257
new=AsyncMock(return_value=zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]),
257258
)
259+
@pytest.mark.looptime
258260
async def test_zha_group_fan_entity(
259261
device_fan_1: Device, device_fan_2: Device, zha_gateway: Gateway
260262
):
@@ -338,12 +340,25 @@ async def test_zha_group_fan_entity(
338340
await send_attributes_report(zha_gateway, dev2_fan_cluster, {0: 2})
339341
await zha_gateway.async_block_till_done()
340342

341-
# test that group fan is speed medium
343+
# no update yet because of debouncing
344+
assert entity.state["is_on"] is False
345+
346+
# member updates are debounced for .5s
347+
await asyncio.sleep(1)
348+
await zha_gateway.async_block_till_done()
349+
342350
assert entity.state["is_on"] is True
343351

344352
await send_attributes_report(zha_gateway, dev2_fan_cluster, {0: 0})
345353
await zha_gateway.async_block_till_done()
346354

355+
# no update yet because of debouncing
356+
assert entity.state["is_on"] is True
357+
358+
# member updates are debounced for .5s
359+
await asyncio.sleep(1)
360+
await zha_gateway.async_block_till_done()
361+
347362
# test that group fan is now off
348363
assert entity.state["is_on"] is False
349364

tests/test_light.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,11 +515,25 @@ async def async_test_on_off_from_light(
515515
# turn on at light
516516
await send_attributes_report(zha_gateway, cluster, {1: 0, 0: 1, 2: 3})
517517
await zha_gateway.async_block_till_done()
518+
519+
# group member updates are debounced
520+
if isinstance(entity, GroupEntity):
521+
assert bool(entity.state["on"]) is False
522+
await asyncio.sleep(0.1)
523+
await zha_gateway.async_block_till_done()
524+
518525
assert bool(entity.state["on"]) is True
519526

520527
# turn off at light
521528
await send_attributes_report(zha_gateway, cluster, {1: 1, 0: 0, 2: 3})
522529
await zha_gateway.async_block_till_done()
530+
531+
# group member updates are debounced
532+
if isinstance(entity, GroupEntity):
533+
assert bool(entity.state["on"]) is True
534+
await asyncio.sleep(0.1)
535+
await zha_gateway.async_block_till_done()
536+
523537
assert bool(entity.state["on"]) is False
524538

525539

@@ -534,6 +548,13 @@ async def async_test_on_from_light(
534548
zha_gateway, cluster, {general.OnOff.AttributeDefs.on_off.id: 1}
535549
)
536550
await zha_gateway.async_block_till_done()
551+
552+
# group member updates are debounced
553+
if isinstance(entity, GroupEntity):
554+
assert bool(entity.state["on"]) is False
555+
await asyncio.sleep(0.1)
556+
await zha_gateway.async_block_till_done()
557+
537558
assert bool(entity.state["on"]) is True
538559

539560

@@ -695,6 +716,10 @@ async def async_test_dimmer_from_light(
695716
if level == 0:
696717
assert entity.state["brightness"] is None
697718
else:
719+
# group member updates are debounced
720+
if isinstance(entity, GroupEntity):
721+
await asyncio.sleep(0.1)
722+
await zha_gateway.async_block_till_done()
698723
assert entity.state["brightness"] == level
699724

700725

@@ -873,6 +898,11 @@ async def test_zha_group_light_entity(
873898
# test that group light is now off
874899
assert device_1_light_entity.state["on"] is False
875900
assert device_2_light_entity.state["on"] is False
901+
902+
# group member updates are debounced
903+
assert bool(entity.state["on"]) is True
904+
await asyncio.sleep(0.1)
905+
await zha_gateway.async_block_till_done()
876906
assert bool(entity.state["on"]) is False
877907

878908
await send_attributes_report(zha_gateway, dev1_cluster_on_off, {0: 1})
@@ -881,13 +911,21 @@ async def test_zha_group_light_entity(
881911
# test that group light is now back on
882912
assert device_1_light_entity.state["on"] is True
883913
assert device_2_light_entity.state["on"] is False
914+
# group member updates are debounced
915+
assert bool(entity.state["on"]) is False
916+
await asyncio.sleep(0.1)
917+
await zha_gateway.async_block_till_done()
884918
assert bool(entity.state["on"]) is True
885919

886920
# turn it off to test a new member add being tracked
887921
await send_attributes_report(zha_gateway, dev1_cluster_on_off, {0: 0})
888922
await zha_gateway.async_block_till_done()
889923
assert device_1_light_entity.state["on"] is False
890924
assert device_2_light_entity.state["on"] is False
925+
# group member updates are debounced
926+
assert bool(entity.state["on"]) is True
927+
await asyncio.sleep(0.1)
928+
await zha_gateway.async_block_till_done()
891929
assert bool(entity.state["on"]) is False
892930

893931
# add a new member and test that his state is also tracked
@@ -905,6 +943,10 @@ async def test_zha_group_light_entity(
905943
assert device_1_light_entity.state["on"] is False
906944
assert device_2_light_entity.state["on"] is False
907945
assert device_3_light_entity.state["on"] is True
946+
# group member updates are debounced
947+
assert bool(entity.state["on"]) is False
948+
await asyncio.sleep(0.1)
949+
await zha_gateway.async_block_till_done()
908950
assert bool(entity.state["on"]) is True
909951

910952
# make the group have only 1 member and now there should be no entity
@@ -941,6 +983,10 @@ async def test_zha_group_light_entity(
941983
await send_attributes_report(zha_gateway, dev1_cluster_on_off, {0: 0})
942984
await send_attributes_report(zha_gateway, dev3_cluster_on_off, {0: 0})
943985
await zha_gateway.async_block_till_done()
986+
# group member updates are debounced
987+
assert bool(entity.state["on"]) is True
988+
await asyncio.sleep(0.1)
989+
await zha_gateway.async_block_till_done()
944990
assert bool(entity.state["on"]) is False
945991

946992
# this will test that _reprobe_group is used correctly
@@ -956,6 +1002,10 @@ async def test_zha_group_light_entity(
9561002
assert entity is not None
9571003
await send_attributes_report(zha_gateway, dev2_cluster_on_off, {0: 1})
9581004
await zha_gateway.async_block_till_done()
1005+
# group member updates are debounced
1006+
assert bool(entity.state["on"]) is False
1007+
await asyncio.sleep(0.1)
1008+
await zha_gateway.async_block_till_done()
9591009
assert bool(entity.state["on"]) is True
9601010

9611011
await zha_group.async_remove_members(

tests/test_switch.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Test zha switch."""
22

3+
import asyncio
34
from collections.abc import Awaitable, Callable
45
import logging
56
from unittest.mock import call, patch
@@ -235,6 +236,7 @@ async def test_switch(
235236
assert bool(entity.state["state"]) is True
236237

237238

239+
@pytest.mark.looptime
238240
async def test_zha_group_switch_entity(
239241
device_switch_1: Device, # pylint: disable=redefined-outer-name
240242
device_switch_2: Device, # pylint: disable=redefined-outer-name
@@ -312,6 +314,11 @@ async def test_zha_group_switch_entity(
312314
await send_attributes_report(zha_gateway, dev2_cluster_on_off, {0: 1})
313315
await zha_gateway.async_block_till_done()
314316

317+
# group member updates are debounced
318+
assert bool(entity.state["state"]) is False
319+
await asyncio.sleep(1)
320+
await zha_gateway.async_block_till_done()
321+
315322
# test that group light is on
316323
assert bool(entity.state["state"]) is True
317324

@@ -324,12 +331,22 @@ async def test_zha_group_switch_entity(
324331
await send_attributes_report(zha_gateway, dev2_cluster_on_off, {0: 0})
325332
await zha_gateway.async_block_till_done()
326333

334+
# group member updates are debounced
335+
assert bool(entity.state["state"]) is True
336+
await asyncio.sleep(1)
337+
await zha_gateway.async_block_till_done()
338+
327339
# test that group light is now off
328340
assert bool(entity.state["state"]) is False
329341

330342
await send_attributes_report(zha_gateway, dev1_cluster_on_off, {0: 1})
331343
await zha_gateway.async_block_till_done()
332344

345+
# group member updates are debounced
346+
assert bool(entity.state["state"]) is False
347+
await asyncio.sleep(1)
348+
await zha_gateway.async_block_till_done()
349+
333350
# test that group light is now back on
334351
assert bool(entity.state["state"]) is True
335352

zha/application/platforms/__init__.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
from zha.application import Platform
1818
from zha.const import STATE_CHANGED
19+
from zha.debounce import Debouncer
20+
from zha.decorators import callback
1921
from zha.event import EventBase
2022
from zha.mixins import LogMixin
2123
from zha.zigbee.cluster_handlers import ClusterHandlerInfo
@@ -29,6 +31,8 @@
2931

3032
_LOGGER = logging.getLogger(__name__)
3133

34+
DEFAULT_UPDATE_GROUP_FROM_CHILD_DELAY: float = 0.5
35+
3236

3337
class EntityCategory(StrEnum):
3438
"""Category of an entity."""
@@ -404,11 +408,22 @@ async def async_update(self) -> None:
404408
class GroupEntity(BaseEntity):
405409
"""A base class for group entities."""
406410

407-
def __init__(self, group: Group) -> None:
411+
def __init__(
412+
self,
413+
group: Group,
414+
update_group_from_member_delay: float = DEFAULT_UPDATE_GROUP_FROM_CHILD_DELAY,
415+
) -> None:
408416
"""Initialize a group."""
409417
super().__init__(unique_id=f"{self.PLATFORM}_zha_group_0x{group.group_id:04x}")
410418
self._attr_fallback_name: str = group.name
411419
self._group: Group = group
420+
self._change_listener_debouncer = Debouncer(
421+
group.gateway,
422+
_LOGGER,
423+
cooldown=update_group_from_member_delay,
424+
immediate=False,
425+
function=self.update,
426+
)
412427
self._group.register_group_entity(self)
413428

414429
@cached_property
@@ -438,6 +453,19 @@ def group(self) -> Group:
438453
"""Return the group."""
439454
return self._group
440455

456+
@callback
457+
def debounced_update(self, _: Any | None = None) -> None:
458+
"""Debounce updating group entity from member entity updates."""
459+
# Delay to ensure that we get updates from all members before updating the group entity
460+
assert self._change_listener_debouncer
461+
self.group.gateway.create_task(self._change_listener_debouncer.async_call())
462+
463+
async def on_remove(self) -> None:
464+
"""Cancel tasks this entity owns."""
465+
await super().on_remove()
466+
if self._change_listener_debouncer:
467+
self._change_listener_debouncer.async_cancel()
468+
441469
@abstractmethod
442470
def update(self, _: Any | None = None) -> None:
443471
"""Update the state of this group entity."""

zha/application/platforms/fan/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
ranged_value_to_percentage,
4141
)
4242
from zha.application.registries import PLATFORM_ENTITIES
43+
from zha.decorators import callback
4344
from zha.zigbee.cluster_handlers import (
4445
ClusterAttributeUpdatedEvent,
4546
wrap_zigpy_exceptions,
@@ -345,6 +346,7 @@ async def _async_set_fan_mode(self, fan_mode: int) -> None:
345346

346347
self.maybe_emit_state_changed_event()
347348

349+
@callback
348350
def update(self, _: Any = None) -> None:
349351
"""Attempt to retrieve on off state from the fan."""
350352
self.debug("Updating fan group entity state")

zha/application/platforms/light/__init__.py

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
)
6666
from zha.application.registries import PLATFORM_ENTITIES
6767
from zha.debounce import Debouncer
68-
from zha.decorators import periodic
68+
from zha.decorators import callback, periodic
6969
from zha.zigbee.cluster_handlers import ClusterAttributeUpdatedEvent
7070
from zha.zigbee.cluster_handlers.const import (
7171
CLUSTER_HANDLER_ATTRIBUTE_UPDATED,
@@ -1128,7 +1128,27 @@ class LightGroup(GroupEntity, BaseLight):
11281128

11291129
def __init__(self, group: Group):
11301130
"""Initialize a light group."""
1131-
super().__init__(group)
1131+
# light groups change the update_group_from_child_delay so we need to do this
1132+
# before calling super
1133+
light_options = group.gateway.config.config.light_options
1134+
self._zha_config_transition = light_options.default_light_transition
1135+
self._zha_config_enable_light_transitioning_flag = (
1136+
light_options.enable_light_transitioning_flag
1137+
)
1138+
self._zha_config_always_prefer_xy_color_mode = (
1139+
light_options.always_prefer_xy_color_mode
1140+
)
1141+
self._zha_config_group_members_assume_state = (
1142+
light_options.group_members_assume_state
1143+
)
1144+
kwargs = {}
1145+
if self._zha_config_group_members_assume_state:
1146+
kwargs["update_group_from_member_delay"] = (
1147+
ASSUME_UPDATE_GROUP_FROM_CHILD_DELAY
1148+
)
1149+
self._zha_config_enhanced_light_transition = False
1150+
1151+
super().__init__(group, **kwargs)
11321152
self._GROUP_SUPPORTS_EXECUTE_IF_OFF: bool = True
11331153

11341154
for member in group.members:
@@ -1163,33 +1183,18 @@ def __init__(self, group: Group):
11631183
self._identify_cluster_handler: None | (
11641184
ClusterHandler
11651185
) = group.zigpy_group.endpoint[Identify.cluster_id]
1166-
self._debounced_member_refresh: Debouncer | None = None
1167-
light_options = group.gateway.config.config.light_options
1168-
self._zha_config_transition = light_options.default_light_transition
1169-
self._zha_config_enable_light_transitioning_flag = (
1170-
light_options.enable_light_transitioning_flag
1171-
)
1172-
self._zha_config_always_prefer_xy_color_mode = (
1173-
light_options.always_prefer_xy_color_mode
1174-
)
1175-
self._zha_config_group_members_assume_state = (
1176-
light_options.group_members_assume_state
1177-
)
1178-
if self._zha_config_group_members_assume_state:
1179-
self._update_group_from_child_delay = ASSUME_UPDATE_GROUP_FROM_CHILD_DELAY
1180-
self._zha_config_enhanced_light_transition = False
11811186

11821187
self._color_mode = ColorMode.UNKNOWN
11831188
self._supported_color_modes = {ColorMode.ONOFF}
11841189

1185-
force_refresh_debouncer = Debouncer(
1190+
self._debounced_member_refresh: Debouncer | None = Debouncer(
11861191
self.group.gateway,
11871192
_LOGGER,
11881193
cooldown=3,
11891194
immediate=True,
11901195
function=self._force_member_updates,
11911196
)
1192-
self._debounced_member_refresh = force_refresh_debouncer
1197+
11931198
if hasattr(self, "info_object"):
11941199
delattr(self, "info_object")
11951200
self.update()
@@ -1241,6 +1246,7 @@ async def async_turn_off(self, **kwargs: Any) -> None:
12411246
if self._debounced_member_refresh:
12421247
await self._debounced_member_refresh.async_call()
12431248

1249+
@callback
12441250
def update(self, _: Any = None) -> None:
12451251
"""Query all members and determine the light group state."""
12461252
self.debug("Updating light group entity state")

zha/application/platforms/switch.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
PlatformEntity,
2525
)
2626
from zha.application.registries import PLATFORM_ENTITIES
27+
from zha.decorators import callback
2728
from zha.zigbee.cluster_handlers import ClusterAttributeUpdatedEvent
2829
from zha.zigbee.cluster_handlers.const import (
2930
CLUSTER_HANDLER_ATTRIBUTE_UPDATED,
@@ -168,6 +169,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: # pylint: disable=unused
168169
self._state = False
169170
self.maybe_emit_state_changed_event()
170171

172+
@callback
171173
def update(self, _: Any | None = None) -> None:
172174
"""Query all members and determine the light group state."""
173175
self.debug("Updating switch group entity state")

0 commit comments

Comments
 (0)