From 0da50c3f1417a4e70dc7173b82089cd51ab0a9e8 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 18 Mar 2024 13:47:41 +0100 Subject: [PATCH 1/5] Modify integration sensor --- .../components/integration/sensor.py | 87 +++++++++++-------- 1 file changed, 50 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 106eb9cc79c..3baa1e3c1d5 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -8,7 +8,7 @@ from datetime import UTC, datetime, timedelta from decimal import Decimal, InvalidOperation from enum import Enum import logging -from typing import Any, Final, Self +from typing import TYPE_CHECKING, Any, Final, Self import voluptuous as vol @@ -27,13 +27,14 @@ from homeassistant.const import ( CONF_METHOD, CONF_NAME, CONF_UNIQUE_ID, + EVENT_STATE_REPORTED, STATE_UNAVAILABLE, UnitOfTime, ) from homeassistant.core import ( CALLBACK_TYPE, Event, - EventStateChangedData, + EventStateReportedData, HomeAssistant, State, callback, @@ -42,7 +43,7 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_call_later, async_track_state_change_event +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -107,9 +108,7 @@ class _IntegrationMethod(ABC): return _NAME_TO_INTEGRATION_METHOD[method_name]() @abstractmethod - def validate_states( - self, left: State, right: State - ) -> tuple[Decimal, Decimal] | None: + def validate_states(self, left: str, right: str) -> tuple[Decimal, Decimal] | None: """Check state requirements for integration.""" @abstractmethod @@ -130,11 +129,9 @@ class _Trapezoidal(_IntegrationMethod): ) -> Decimal: return elapsed_time * (left + right) / 2 - def validate_states( - self, left: State, right: State - ) -> tuple[Decimal, Decimal] | None: - if (left_dec := _decimal_state(left.state)) is None or ( - right_dec := _decimal_state(right.state) + def validate_states(self, left: str, right: str) -> tuple[Decimal, Decimal] | None: + if (left_dec := _decimal_state(left)) is None or ( + right_dec := _decimal_state(right) ) is None: return None return (left_dec, right_dec) @@ -146,10 +143,8 @@ class _Left(_IntegrationMethod): ) -> Decimal: return self.calculate_area_with_one_state(elapsed_time, left) - def validate_states( - self, left: State, right: State - ) -> tuple[Decimal, Decimal] | None: - if (left_dec := _decimal_state(left.state)) is None: + def validate_states(self, left: str, right: str) -> tuple[Decimal, Decimal] | None: + if (left_dec := _decimal_state(left)) is None: return None return (left_dec, left_dec) @@ -160,10 +155,8 @@ class _Right(_IntegrationMethod): ) -> Decimal: return self.calculate_area_with_one_state(elapsed_time, right) - def validate_states( - self, left: State, right: State - ) -> tuple[Decimal, Decimal] | None: - if (right_dec := _decimal_state(right.state)) is None: + def validate_states(self, left: str, right: str) -> tuple[Decimal, Decimal] | None: + if (right_dec := _decimal_state(right)) is None: return None return (right_dec, right_dec) @@ -183,7 +176,7 @@ _NAME_TO_INTEGRATION_METHOD: dict[str, type[_IntegrationMethod]] = { class _IntegrationTrigger(Enum): - StateChange = "state_change" + StateReport = "state_report" TimeElapsed = "time_elapsed" @@ -343,7 +336,7 @@ class IntegrationSensor(RestoreSensor): ) self._max_sub_interval_exceeded_callback: CALLBACK_TYPE = lambda *args: None self._last_integration_time: datetime = datetime.now(tz=UTC) - self._last_integration_trigger = _IntegrationTrigger.StateChange + self._last_integration_trigger = _IntegrationTrigger.StateReport self._attr_suggested_display_precision = round_digits or 2 def _calculate_unit(self, source_unit: str) -> str: @@ -443,16 +436,19 @@ class IntegrationSensor(RestoreSensor): self._derive_and_set_attributes_from_state(state) self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._sensor_source_id], + self.hass.bus.async_listen( + EVENT_STATE_REPORTED, handle_state_change, + event_filter=callback( + lambda event_data: event_data["entity_id"] == self._sensor_source_id + ), + run_immediately=True, ) ) @callback def _integrate_on_state_change_and_max_sub_interval( - self, event: Event[EventStateChangedData] + self, event: Event[EventStateReportedData] ) -> None: """Integrate based on state change and time. @@ -460,11 +456,12 @@ class IntegrationSensor(RestoreSensor): reschedules time based integration. """ self._cancel_max_sub_interval_exceeded_callback() - old_state = event.data["old_state"] + old_last_reported = event.data.get("old_last_reported") + old_state = event.data.get("old_state") new_state = event.data["new_state"] try: - self._integrate_on_state_change(old_state, new_state) - self._last_integration_trigger = _IntegrationTrigger.StateChange + self._integrate_on_state_change(old_last_reported, old_state, new_state) + self._last_integration_trigger = _IntegrationTrigger.StateReport self._last_integration_time = datetime.now(tz=UTC) finally: # When max_sub_interval exceeds without state change the source is assumed @@ -473,15 +470,19 @@ class IntegrationSensor(RestoreSensor): @callback def _integrate_on_state_change_callback( - self, event: Event[EventStateChangedData] + self, event: Event[EventStateReportedData] ) -> None: """Handle the sensor state changes.""" - old_state = event.data["old_state"] + old_last_reported = event.data.get("old_last_reported") + old_state = event.data.get("old_state") new_state = event.data["new_state"] - return self._integrate_on_state_change(old_state, new_state) + return self._integrate_on_state_change(old_last_reported, old_state, new_state) def _integrate_on_state_change( - self, old_state: State | None, new_state: State | None + self, + old_last_reported: datetime | None, + old_state: State | None, + new_state: State | None, ) -> None: if new_state is None: return @@ -491,21 +492,33 @@ class IntegrationSensor(RestoreSensor): self.async_write_ha_state() return + if old_state: + # state has changed, we recover old_state from the event + old_state_state = old_state.state + old_last_reported = old_state.last_reported + else: + # event state reported without any state change + old_state_state = new_state.state + self._attr_available = True self._derive_and_set_attributes_from_state(new_state) - if old_state is None: + if old_last_reported is None and old_state is None: self.async_write_ha_state() return - if not (states := self._method.validate_states(old_state, new_state)): + if not ( + states := self._method.validate_states(old_state_state, new_state.state) + ): self.async_write_ha_state() return + if TYPE_CHECKING: + assert old_last_reported is not None elapsed_seconds = Decimal( - (new_state.last_updated - old_state.last_updated).total_seconds() - if self._last_integration_trigger == _IntegrationTrigger.StateChange - else (new_state.last_updated - self._last_integration_time).total_seconds() + (new_state.last_reported - old_last_reported).total_seconds() + if self._last_integration_trigger == _IntegrationTrigger.StateReport + else (new_state.last_reported - self._last_integration_time).total_seconds() ) area = self._method.calculate_area_with_two_states(elapsed_seconds, *states) From 2058777db621dcc216273ceb32e089827c7ca2c9 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Jun 2024 15:05:46 +0200 Subject: [PATCH 2/5] Update tests --- tests/components/integration/test_sensor.py | 78 ++++++--------------- 1 file changed, 21 insertions(+), 57 deletions(-) diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 03df38893a2..dccf2e408a7 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -294,28 +294,16 @@ async def test_restore_state_failed(hass: HomeAssistant, extra_attributes) -> No assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize("force_update", [False, True]) @pytest.mark.parametrize( - ("force_update", "sequence"), + "sequence", [ ( - False, - ( - (20, 10, 1.67), - (30, 30, 5.0), - (40, 5, 7.92), - (50, 5, 7.92), - (60, 0, 8.75), - ), - ), - ( - True, - ( - (20, 10, 1.67), - (30, 30, 5.0), - (40, 5, 7.92), - (50, 5, 8.75), - (60, 0, 9.17), - ), + (20, 10, 1.67), + (30, 30, 5.0), + (40, 5, 7.92), + (50, 5, 8.75), + (60, 0, 9.17), ), ], ) @@ -358,28 +346,16 @@ async def test_trapezoidal( assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.KILO_WATT_HOUR +@pytest.mark.parametrize("force_update", [False, True]) @pytest.mark.parametrize( - ("force_update", "sequence"), + "sequence", [ ( - False, - ( - (20, 10, 0.0), - (30, 30, 1.67), - (40, 5, 6.67), - (50, 5, 6.67), - (60, 0, 8.33), - ), - ), - ( - True, - ( - (20, 10, 0.0), - (30, 30, 1.67), - (40, 5, 6.67), - (50, 5, 7.5), - (60, 0, 8.33), - ), + (20, 10, 0.0), + (30, 30, 1.67), + (40, 5, 6.67), + (50, 5, 7.5), + (60, 0, 8.33), ), ], ) @@ -425,28 +401,16 @@ async def test_left( assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.KILO_WATT_HOUR +@pytest.mark.parametrize("force_update", [False, True]) @pytest.mark.parametrize( - ("force_update", "sequence"), + "sequence", [ ( - False, - ( - (20, 10, 3.33), - (30, 30, 8.33), - (40, 5, 9.17), - (50, 5, 9.17), - (60, 0, 9.17), - ), - ), - ( - True, - ( - (20, 10, 3.33), - (30, 30, 8.33), - (40, 5, 9.17), - (50, 5, 10.0), - (60, 0, 10.0), - ), + (20, 10, 3.33), + (30, 30, 8.33), + (40, 5, 9.17), + (50, 5, 10.0), + (60, 0, 10.0), ), ], ) From 9cb8b5be54c91666b1176822fa02bd0a5cedcc6b Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 26 Jun 2024 11:48:15 +0200 Subject: [PATCH 3/5] Subscribe also to EVENT_STATE_CHANGED --- .../components/integration/sensor.py | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 3baa1e3c1d5..dae0bcf3ee3 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -27,6 +27,7 @@ from homeassistant.const import ( CONF_METHOD, CONF_NAME, CONF_UNIQUE_ID, + EVENT_STATE_CHANGED, EVENT_STATE_REPORTED, STATE_UNAVAILABLE, UnitOfTime, @@ -34,6 +35,7 @@ from homeassistant.const import ( from homeassistant.core import ( CALLBACK_TYPE, Event, + EventStateChangedData, EventStateReportedData, HomeAssistant, State, @@ -435,6 +437,16 @@ class IntegrationSensor(RestoreSensor): ) and state.state != STATE_UNAVAILABLE: self._derive_and_set_attributes_from_state(state) + self.async_on_remove( + self.hass.bus.async_listen( + EVENT_STATE_CHANGED, + handle_state_change, + event_filter=callback( + lambda event_data: event_data["entity_id"] == self._sensor_source_id + ), + run_immediately=True, + ) + ) self.async_on_remove( self.hass.bus.async_listen( EVENT_STATE_REPORTED, @@ -448,7 +460,7 @@ class IntegrationSensor(RestoreSensor): @callback def _integrate_on_state_change_and_max_sub_interval( - self, event: Event[EventStateReportedData] + self, event: Event[EventStateChangedData] | Event[EventStateReportedData] ) -> None: """Integrate based on state change and time. @@ -456,8 +468,17 @@ class IntegrationSensor(RestoreSensor): reschedules time based integration. """ self._cancel_max_sub_interval_exceeded_callback() - old_last_reported = event.data.get("old_last_reported") - old_state = event.data.get("old_state") + if event.event_type == EVENT_STATE_CHANGED: + if TYPE_CHECKING: + assert type(event.data) is EventStateChangedData + old_last_reported = None + old_state = event.data["old_state"] + else: # EVENT_STATE_REPORTED + if TYPE_CHECKING: + assert type(event.data) is EventStateReportedData + old_last_reported = event.data["old_last_reported"] + old_state = None + new_state = event.data["new_state"] try: self._integrate_on_state_change(old_last_reported, old_state, new_state) @@ -470,11 +491,19 @@ class IntegrationSensor(RestoreSensor): @callback def _integrate_on_state_change_callback( - self, event: Event[EventStateReportedData] + self, event: Event[EventStateChangedData] | Event[EventStateReportedData] ) -> None: """Handle the sensor state changes.""" - old_last_reported = event.data.get("old_last_reported") - old_state = event.data.get("old_state") + if event.event_type == EVENT_STATE_CHANGED: + if TYPE_CHECKING: + assert type(event.data) is EventStateChangedData + old_last_reported = None + old_state = event.data["old_state"] + else: # EVENT_STATE_REPORTED + if TYPE_CHECKING: + assert type(event.data) is EventStateReportedData + old_last_reported = event.data["old_last_reported"] + old_state = None new_state = event.data["new_state"] return self._integrate_on_state_change(old_last_reported, old_state, new_state) From 423f4c5bcfcd0c2dbb6463372c9200200eb95c89 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 26 Jun 2024 13:25:52 +0200 Subject: [PATCH 4/5] Refactor event handlers --- .../components/integration/sensor.py | 79 +++++++++++-------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index dae0bcf3ee3..60cbee5549f 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -178,7 +178,7 @@ _NAME_TO_INTEGRATION_METHOD: dict[str, type[_IntegrationMethod]] = { class _IntegrationTrigger(Enum): - StateReport = "state_report" + StateEvent = "state_event" TimeElapsed = "time_elapsed" @@ -338,7 +338,7 @@ class IntegrationSensor(RestoreSensor): ) self._max_sub_interval_exceeded_callback: CALLBACK_TYPE = lambda *args: None self._last_integration_time: datetime = datetime.now(tz=UTC) - self._last_integration_trigger = _IntegrationTrigger.StateReport + self._last_integration_trigger = _IntegrationTrigger.StateEvent self._attr_suggested_display_precision = round_digits or 2 def _calculate_unit(self, source_unit: str) -> str: @@ -428,9 +428,11 @@ class IntegrationSensor(RestoreSensor): source_state = self.hass.states.get(self._sensor_source_id) self._schedule_max_sub_interval_exceeded_if_state_is_numeric(source_state) self.async_on_remove(self._cancel_max_sub_interval_exceeded_callback) - handle_state_change = self._integrate_on_state_change_and_max_sub_interval + handle_state_change = self._integrate_on_state_change_with_max_sub_interval + handle_state_report = self._integrate_on_state_report_with_max_sub_interval else: handle_state_change = self._integrate_on_state_change_callback + handle_state_report = self._integrate_on_state_report_callback if ( state := self.hass.states.get(self._source_entity) @@ -450,7 +452,7 @@ class IntegrationSensor(RestoreSensor): self.async_on_remove( self.hass.bus.async_listen( EVENT_STATE_REPORTED, - handle_state_change, + handle_state_report, event_filter=callback( lambda event_data: event_data["entity_id"] == self._sensor_source_id ), @@ -459,8 +461,29 @@ class IntegrationSensor(RestoreSensor): ) @callback - def _integrate_on_state_change_and_max_sub_interval( - self, event: Event[EventStateChangedData] | Event[EventStateReportedData] + def _integrate_on_state_change_with_max_sub_interval( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle sensor state update when sub interval is configured.""" + self._integrate_on_state_update_with_max_sub_interval( + None, event.data["old_state"], event.data["new_state"] + ) + + @callback + def _integrate_on_state_report_with_max_sub_interval( + self, event: Event[EventStateReportedData] + ) -> None: + """Handle sensor state report when sub interval is configured.""" + self._integrate_on_state_update_with_max_sub_interval( + event.data["old_last_reported"], None, event.data["new_state"] + ) + + @callback + def _integrate_on_state_update_with_max_sub_interval( + self, + old_last_reported: datetime | None, + old_state: State | None, + new_state: State | None, ) -> None: """Integrate based on state change and time. @@ -468,21 +491,9 @@ class IntegrationSensor(RestoreSensor): reschedules time based integration. """ self._cancel_max_sub_interval_exceeded_callback() - if event.event_type == EVENT_STATE_CHANGED: - if TYPE_CHECKING: - assert type(event.data) is EventStateChangedData - old_last_reported = None - old_state = event.data["old_state"] - else: # EVENT_STATE_REPORTED - if TYPE_CHECKING: - assert type(event.data) is EventStateReportedData - old_last_reported = event.data["old_last_reported"] - old_state = None - - new_state = event.data["new_state"] try: self._integrate_on_state_change(old_last_reported, old_state, new_state) - self._last_integration_trigger = _IntegrationTrigger.StateReport + self._last_integration_trigger = _IntegrationTrigger.StateEvent self._last_integration_time = datetime.now(tz=UTC) finally: # When max_sub_interval exceeds without state change the source is assumed @@ -491,21 +502,21 @@ class IntegrationSensor(RestoreSensor): @callback def _integrate_on_state_change_callback( - self, event: Event[EventStateChangedData] | Event[EventStateReportedData] + self, event: Event[EventStateChangedData] ) -> None: - """Handle the sensor state changes.""" - if event.event_type == EVENT_STATE_CHANGED: - if TYPE_CHECKING: - assert type(event.data) is EventStateChangedData - old_last_reported = None - old_state = event.data["old_state"] - else: # EVENT_STATE_REPORTED - if TYPE_CHECKING: - assert type(event.data) is EventStateReportedData - old_last_reported = event.data["old_last_reported"] - old_state = None - new_state = event.data["new_state"] - return self._integrate_on_state_change(old_last_reported, old_state, new_state) + """Handle sensor state change.""" + return self._integrate_on_state_change( + None, event.data["old_state"], event.data["new_state"] + ) + + @callback + def _integrate_on_state_report_callback( + self, event: Event[EventStateReportedData] + ) -> None: + """Handle sensor state report.""" + return self._integrate_on_state_change( + event.data["old_last_reported"], None, event.data["new_state"] + ) def _integrate_on_state_change( self, @@ -546,7 +557,7 @@ class IntegrationSensor(RestoreSensor): assert old_last_reported is not None elapsed_seconds = Decimal( (new_state.last_reported - old_last_reported).total_seconds() - if self._last_integration_trigger == _IntegrationTrigger.StateReport + if self._last_integration_trigger == _IntegrationTrigger.StateEvent else (new_state.last_reported - self._last_integration_time).total_seconds() ) From 903054db1455c1dcc3867e09405f505e01cc1fcc Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 26 Jun 2024 13:37:11 +0200 Subject: [PATCH 5/5] Refactor event handlers --- .../components/integration/sensor.py | 52 +++++++------------ 1 file changed, 18 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 60cbee5549f..484641770fa 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -3,10 +3,12 @@ from __future__ import annotations from abc import ABC, abstractmethod +from collections.abc import Callable from dataclasses import dataclass from datetime import UTC, datetime, timedelta from decimal import Decimal, InvalidOperation from enum import Enum +from functools import partial import logging from typing import TYPE_CHECKING, Any, Final, Self @@ -103,6 +105,8 @@ PLATFORM_SCHEMA = vol.All( ), ) +type StateUpdateFunc = Callable[[datetime | None, State | None, State | None], None] + class _IntegrationMethod(ABC): @staticmethod @@ -428,11 +432,9 @@ class IntegrationSensor(RestoreSensor): source_state = self.hass.states.get(self._sensor_source_id) self._schedule_max_sub_interval_exceeded_if_state_is_numeric(source_state) self.async_on_remove(self._cancel_max_sub_interval_exceeded_callback) - handle_state_change = self._integrate_on_state_change_with_max_sub_interval - handle_state_report = self._integrate_on_state_report_with_max_sub_interval + handle_state_update = self._integrate_on_state_update_with_max_sub_interval else: - handle_state_change = self._integrate_on_state_change_callback - handle_state_report = self._integrate_on_state_report_callback + handle_state_update = self._integrate_on_state_update if ( state := self.hass.states.get(self._source_entity) @@ -442,7 +444,7 @@ class IntegrationSensor(RestoreSensor): self.async_on_remove( self.hass.bus.async_listen( EVENT_STATE_CHANGED, - handle_state_change, + partial(self._integrate_on_state_change_callback, handle_state_update), event_filter=callback( lambda event_data: event_data["entity_id"] == self._sensor_source_id ), @@ -452,7 +454,7 @@ class IntegrationSensor(RestoreSensor): self.async_on_remove( self.hass.bus.async_listen( EVENT_STATE_REPORTED, - handle_state_report, + partial(self._integrate_on_state_report_callback, handle_state_update), event_filter=callback( lambda event_data: event_data["entity_id"] == self._sensor_source_id ), @@ -461,20 +463,20 @@ class IntegrationSensor(RestoreSensor): ) @callback - def _integrate_on_state_change_with_max_sub_interval( - self, event: Event[EventStateChangedData] + def _integrate_on_state_change_callback( + self, handle_state_update: StateUpdateFunc, event: Event[EventStateChangedData] ) -> None: - """Handle sensor state update when sub interval is configured.""" - self._integrate_on_state_update_with_max_sub_interval( + """Handle sensor state change.""" + return handle_state_update( None, event.data["old_state"], event.data["new_state"] ) @callback - def _integrate_on_state_report_with_max_sub_interval( - self, event: Event[EventStateReportedData] + def _integrate_on_state_report_callback( + self, handle_state_update: StateUpdateFunc, event: Event[EventStateReportedData] ) -> None: - """Handle sensor state report when sub interval is configured.""" - self._integrate_on_state_update_with_max_sub_interval( + """Handle sensor state report.""" + return handle_state_update( event.data["old_last_reported"], None, event.data["new_state"] ) @@ -492,7 +494,7 @@ class IntegrationSensor(RestoreSensor): """ self._cancel_max_sub_interval_exceeded_callback() try: - self._integrate_on_state_change(old_last_reported, old_state, new_state) + self._integrate_on_state_update(old_last_reported, old_state, new_state) self._last_integration_trigger = _IntegrationTrigger.StateEvent self._last_integration_time = datetime.now(tz=UTC) finally: @@ -500,25 +502,7 @@ class IntegrationSensor(RestoreSensor): # constant with the last known state (new_state). self._schedule_max_sub_interval_exceeded_if_state_is_numeric(new_state) - @callback - def _integrate_on_state_change_callback( - self, event: Event[EventStateChangedData] - ) -> None: - """Handle sensor state change.""" - return self._integrate_on_state_change( - None, event.data["old_state"], event.data["new_state"] - ) - - @callback - def _integrate_on_state_report_callback( - self, event: Event[EventStateReportedData] - ) -> None: - """Handle sensor state report.""" - return self._integrate_on_state_change( - event.data["old_last_reported"], None, event.data["new_state"] - ) - - def _integrate_on_state_change( + def _integrate_on_state_update( self, old_last_reported: datetime | None, old_state: State | None,