From 141b5a4c31b201d0fb49fa9b24fae89291cf40cc Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 5 Mar 2025 11:05:30 -0500 Subject: [PATCH 1/6] Batch state updates for reporting power plugs --- zha/application/platforms/sensor/__init__.py | 64 +++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/zha/application/platforms/sensor/__init__.py b/zha/application/platforms/sensor/__init__.py index 5c616ef1..37f58cc9 100644 --- a/zha/application/platforms/sensor/__init__.py +++ b/zha/application/platforms/sensor/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from asyncio import Task +import asyncio from dataclasses import dataclass from datetime import UTC, date, datetime import enum @@ -329,7 +329,7 @@ def __init__( ) -> None: """Init this sensor.""" super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) - self._polling_task: Task | None = None + self._polling_task: asyncio.Task | None = None def on_add(self) -> None: """Run when entity is added.""" @@ -604,6 +604,10 @@ class ElectricalMeasurement(PollableSensor): _multiplier_attribute_name: str | None = "ac_power_multiplier" _attr_max_attribute_name: str = None + # The final state is computed from up to three attributes, wait for them all to come + # in before emitting a change + _aggregate_attribute_reports_timeout: float = 1.0 + def __init__( self, unique_id: str, @@ -619,6 +623,62 @@ def __init__( self._max_attribute_name, } + self._pending_state_update_attributes: set[str] = set() + self._pending_state_update_timer: asyncio.TimerHandle | None = None + + def handle_cluster_handler_attribute_updated( + self, + event: ClusterAttributeUpdatedEvent, + ) -> None: + """Handle attribute updates from the cluster handler.""" + if not ( + event.attribute_name == self._attribute_name + or event.attribute_name in self._attr_extra_state_attribute_names + ): + super().handle_cluster_handler_attribute_updated(event) + return + + # We need to wait for all of the relevant attributes to be received before we + # can emit a state change event + if not self._pending_state_update_attributes: + self._pending_state_update_attributes = { + attr_name + for attr_name in ( + (self._attribute_name,) + + tuple(self._attr_extra_state_attribute_names) + ) + if attr_name not in self._cluster_handler.cluster.unsupported_attributes + } + + loop = asyncio.get_running_loop() + self._pending_state_update_timer = loop.call_later( + self._aggregate_attribute_reports_timeout, + self._emit_state_change_after_attributes_received, + ) + + self._pending_state_update_attributes.discard(event.attribute_name) + _LOGGER.debug( + "Waiting for attributes to be reported before changing state: %s", + self._pending_state_update_attributes, + ) + + if not self._pending_state_update_attributes: + self._emit_state_change_after_attributes_received() + + def _emit_state_change_after_attributes_received(self) -> None: + """Emit a state change after all attributes have been received.""" + self._pending_state_update_attributes.clear() + + if self._pending_state_update_timer is not None: + self._pending_state_update_timer.cancel() + self._pending_state_update_timer = None + + _LOGGER.debug( + "Emitting state changed event, pending attributes: %s", + self._pending_state_update_attributes, + ) + self.maybe_emit_state_changed_event() + @property def _max_attribute_name(self) -> str: """Return the max attribute name.""" From 4ad45dcdeeecfe3030d6de8bac6eff887e68d0ad Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 5 Mar 2025 11:11:19 -0500 Subject: [PATCH 2/6] Clean up the timer when we are removing the entity --- zha/application/platforms/sensor/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/zha/application/platforms/sensor/__init__.py b/zha/application/platforms/sensor/__init__.py index 37f58cc9..c67bd225 100644 --- a/zha/application/platforms/sensor/__init__.py +++ b/zha/application/platforms/sensor/__init__.py @@ -626,6 +626,14 @@ def __init__( self._pending_state_update_attributes: set[str] = set() self._pending_state_update_timer: asyncio.TimerHandle | None = None + async def on_remove(self) -> None: + """Run when entity is removed.""" + if self._pending_state_update_timer is not None: + self._pending_state_update_timer.cancel() + self._pending_state_update_timer = None + + await super().on_remove() + def handle_cluster_handler_attribute_updated( self, event: ClusterAttributeUpdatedEvent, From 238c096cac045f04bd7cda4e9be1db7e07831b82 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 5 Mar 2025 13:49:06 -0500 Subject: [PATCH 3/6] Increase timeout to 4s --- zha/application/platforms/sensor/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zha/application/platforms/sensor/__init__.py b/zha/application/platforms/sensor/__init__.py index c67bd225..230887a5 100644 --- a/zha/application/platforms/sensor/__init__.py +++ b/zha/application/platforms/sensor/__init__.py @@ -606,7 +606,7 @@ class ElectricalMeasurement(PollableSensor): # The final state is computed from up to three attributes, wait for them all to come # in before emitting a change - _aggregate_attribute_reports_timeout: float = 1.0 + _aggregate_attribute_reports_timeout: float = 4.0 def __init__( self, From 13a824de7988d1ef24c8b1bb035a06b660b10848 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 5 Mar 2025 13:49:24 -0500 Subject: [PATCH 4/6] Exclude `measurement_type` and consolidate --- zha/application/platforms/sensor/__init__.py | 33 ++++++++++++++------ 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/zha/application/platforms/sensor/__init__.py b/zha/application/platforms/sensor/__init__.py index 230887a5..c7468c73 100644 --- a/zha/application/platforms/sensor/__init__.py +++ b/zha/application/platforms/sensor/__init__.py @@ -626,6 +626,26 @@ def __init__( self._pending_state_update_attributes: set[str] = set() self._pending_state_update_timer: asyncio.TimerHandle | None = None + @property + def _all_state_update_attributes(self) -> set[str]: + """Return a set of attributes that are required to compute state.""" + return { + attr_name + for attr_name in ( + ( + self._attribute_name, + self._divisor_attribute_name, + self._multiplier_attribute_name, + ) + + tuple(self._attr_extra_state_attribute_names) + ) + if ( + attr_name is not None + and attr_name + not in self._cluster_handler.cluster.unsupported_attributes + ) + } - {"measurement_type"} + async def on_remove(self) -> None: """Run when entity is removed.""" if self._pending_state_update_timer is not None: @@ -639,7 +659,9 @@ def handle_cluster_handler_attribute_updated( event: ClusterAttributeUpdatedEvent, ) -> None: """Handle attribute updates from the cluster handler.""" - if not ( + state_update_attrs = self._all_state_update_attributes + + if len(state_update_attrs) == 1 or not ( event.attribute_name == self._attribute_name or event.attribute_name in self._attr_extra_state_attribute_names ): @@ -649,14 +671,7 @@ def handle_cluster_handler_attribute_updated( # We need to wait for all of the relevant attributes to be received before we # can emit a state change event if not self._pending_state_update_attributes: - self._pending_state_update_attributes = { - attr_name - for attr_name in ( - (self._attribute_name,) - + tuple(self._attr_extra_state_attribute_names) - ) - if attr_name not in self._cluster_handler.cluster.unsupported_attributes - } + self._pending_state_update_attributes = state_update_attrs loop = asyncio.get_running_loop() self._pending_state_update_timer = loop.call_later( From cfb91e405dc507df9adb38fcbee94006a10fffc6 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 5 Mar 2025 13:53:24 -0500 Subject: [PATCH 5/6] Drop timeout to 2s --- zha/application/platforms/sensor/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zha/application/platforms/sensor/__init__.py b/zha/application/platforms/sensor/__init__.py index c7468c73..6bc8719d 100644 --- a/zha/application/platforms/sensor/__init__.py +++ b/zha/application/platforms/sensor/__init__.py @@ -606,7 +606,7 @@ class ElectricalMeasurement(PollableSensor): # The final state is computed from up to three attributes, wait for them all to come # in before emitting a change - _aggregate_attribute_reports_timeout: float = 4.0 + _aggregate_attribute_reports_timeout: float = 2.0 def __init__( self, From 54bfc735dd977e8ec77fbbb6714941e1f9d1ca7c Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 5 Mar 2025 16:04:53 -0500 Subject: [PATCH 6/6] Recompute state if we receive an unexpected attribute report --- zha/application/platforms/sensor/__init__.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/zha/application/platforms/sensor/__init__.py b/zha/application/platforms/sensor/__init__.py index 6bc8719d..ba53845e 100644 --- a/zha/application/platforms/sensor/__init__.py +++ b/zha/application/platforms/sensor/__init__.py @@ -679,14 +679,19 @@ def handle_cluster_handler_attribute_updated( self._emit_state_change_after_attributes_received, ) - self._pending_state_update_attributes.discard(event.attribute_name) - _LOGGER.debug( - "Waiting for attributes to be reported before changing state: %s", - self._pending_state_update_attributes, - ) - - if not self._pending_state_update_attributes: + # If we have no attributes to wait for *or* we receive a new attribute report + # for an existing attribute during a timeout window, we need to emit immediately + if ( + not self._pending_state_update_attributes + or event.attribute_name not in self._pending_state_update_attributes + ): self._emit_state_change_after_attributes_received() + else: + self._pending_state_update_attributes.discard(event.attribute_name) + _LOGGER.debug( + "Waiting for attributes to be reported before changing state: %s", + self._pending_state_update_attributes, + ) def _emit_state_change_after_attributes_received(self) -> None: """Emit a state change after all attributes have been received."""