From 697d8242d02d4454c223c493da53911583a86761 Mon Sep 17 00:00:00 2001 From: Dave Brand Date: Wed, 27 Nov 2024 10:45:27 -0500 Subject: [PATCH 1/6] =?UTF-8?q?Update=20TimeSMAFilter=20based=20on=20time?= =?UTF-8?q?=20Based=20on=20deleted=20branch=20by=20Daniel=20Hjelseth=20H?= =?UTF-8?q?=C3=B8yer=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/filter/sensor.py | 25 +++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 549d74ffd098f..e9ea235db150e 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -86,6 +86,8 @@ NAME_TEMPLATE = "{} filter" ICON = "mdi:chart-line-variant" +SCAN_INTERVAL = timedelta(minutes=10) + FILTER_SCHEMA = vol.Schema({vol.Optional(CONF_FILTER_PRECISION): vol.Coerce(int)}) FILTER_OUTLIER_SCHEMA = FILTER_SCHEMA.extend( @@ -223,6 +225,12 @@ def __init__( self._attr_state_class = None self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_id} + self._attr_should_poll = False + for filt in filters: + if isinstance(filt, TimeSMAFilter): + self._attr_should_poll = True + break + @callback def _update_filter_sensor_state_event( self, event: Event[EventStateChangedData] @@ -231,6 +239,11 @@ def _update_filter_sensor_state_event( _LOGGER.debug("Update filter on event: %s", event) self._update_filter_sensor_state(event.data["new_state"]) + def update(self): + """Update TimeSMAFilter value.""" + _LOGGER.debug("Update filter: %s", self._state) + self._update_filter_sensor_state(self.hass.states.get(self._entity)) + @callback def _update_filter_sensor_state( self, new_state: State | None, update_ha: bool = True @@ -492,7 +505,7 @@ def filter_state(self, new_state: _State) -> _State: @FILTERS.register(FILTER_NAME_RANGE) -class RangeFilter(Filter, SensorEntity): +class RangeFilter(Filter): """Range filter. Determines if new state is in the range of upper_bound and lower_bound. @@ -551,7 +564,7 @@ def _filter_state(self, new_state: FilterState) -> FilterState: @FILTERS.register(FILTER_NAME_OUTLIER) -class OutlierFilter(Filter, SensorEntity): +class OutlierFilter(Filter): """BASIC outlier filter. Determines if new state is in a band around the median. @@ -601,7 +614,7 @@ def _filter_state(self, new_state: FilterState) -> FilterState: @FILTERS.register(FILTER_NAME_LOWPASS) -class LowPassFilter(Filter, SensorEntity): +class LowPassFilter(Filter): """BASIC Low Pass Filter.""" def __init__( @@ -635,7 +648,7 @@ def _filter_state(self, new_state: FilterState) -> FilterState: @FILTERS.register(FILTER_NAME_TIME_SMA) -class TimeSMAFilter(Filter, SensorEntity): +class TimeSMAFilter(Filter): """Simple Moving Average (SMA) Filter. The window_size is determined by time, and SMA is time weighted. @@ -690,7 +703,7 @@ def _filter_state(self, new_state: FilterState) -> FilterState: @FILTERS.register(FILTER_NAME_THROTTLE) -class ThrottleFilter(Filter, SensorEntity): +class ThrottleFilter(Filter): """Throttle Filter. One sample per window. @@ -717,7 +730,7 @@ def _filter_state(self, new_state: FilterState) -> FilterState: @FILTERS.register(FILTER_NAME_TIME_THROTTLE) -class TimeThrottleFilter(Filter, SensorEntity): +class TimeThrottleFilter(Filter): """Time Throttle Filter. One sample per time period. From 25773f249fbc015f970e43eb455a26a32a7d820b Mon Sep 17 00:00:00 2001 From: Dave Brand Date: Wed, 27 Nov 2024 10:47:32 -0500 Subject: [PATCH 2/6] =?UTF-8?q?Filter,=20config=20Based=20on=20deleted=20b?= =?UTF-8?q?ranch=20by=20Daniel=20Hjelseth=20H=C3=B8yer=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/filter/sensor.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index e9ea235db150e..1f7a91f26392a 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -69,6 +69,7 @@ CONF_FILTER_PRECISION = "precision" CONF_FILTER_RADIUS = "radius" CONF_FILTER_TIME_CONSTANT = "time_constant" +CONF_FILTER_UPDATE_BY_TIME = "update_by_time" CONF_FILTER_LOWER_BOUND = "lower_bound" CONF_FILTER_UPPER_BOUND = "upper_bound" CONF_TIME_SMA_TYPE = "type" @@ -86,7 +87,7 @@ NAME_TEMPLATE = "{} filter" ICON = "mdi:chart-line-variant" -SCAN_INTERVAL = timedelta(minutes=10) +SCAN_INTERVAL = timedelta(minutes=3) FILTER_SCHEMA = vol.Schema({vol.Optional(CONF_FILTER_PRECISION): vol.Coerce(int)}) @@ -131,6 +132,7 @@ vol.Required(CONF_FILTER_WINDOW_SIZE): vol.All( cv.time_period, cv.positive_timedelta ), + vol.Optional(CONF_FILTER_UPDATE_BY_TIME, default=False): cv.boolean, } ) @@ -227,7 +229,7 @@ def __init__( self._attr_should_poll = False for filt in filters: - if isinstance(filt, TimeSMAFilter): + if getattr(filt, "update_by_time", False): self._attr_should_poll = True break @@ -661,6 +663,7 @@ def __init__( entity: str, type: str, # pylint: disable=redefined-builtin precision: int = DEFAULT_PRECISION, + update_by_time: bool = False, ) -> None: """Initialize Filter. @@ -670,6 +673,7 @@ def __init__( FILTER_NAME_TIME_SMA, window_size, precision=precision, entity=entity ) self._time_window = window_size + self.update_by_time = update_by_time self.last_leak: FilterState | None = None self.queue = deque[FilterState]() From c5cbe85b3d1f1cc30ef229d5717086965ce35dde Mon Sep 17 00:00:00 2001 From: Dave Brand Date: Wed, 27 Nov 2024 12:25:35 -0500 Subject: [PATCH 3/6] Fix and add test for new update_by_time flag Note: Since we can't set SCAN_INTERVAL per filter, I'm just letting it default. --- homeassistant/components/filter/sensor.py | 57 ++++++++++++----------- pyproject.toml | 3 ++ tests/components/filter/test_sensor.py | 49 ++++++++++++++++++- 3 files changed, 82 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 1f7a91f26392a..46fb16eeedfb8 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -87,8 +87,6 @@ NAME_TEMPLATE = "{} filter" ICON = "mdi:chart-line-variant" -SCAN_INTERVAL = timedelta(minutes=3) - FILTER_SCHEMA = vol.Schema({vol.Optional(CONF_FILTER_PRECISION): vol.Coerce(int)}) FILTER_OUTLIER_SCHEMA = FILTER_SCHEMA.extend( @@ -241,10 +239,10 @@ def _update_filter_sensor_state_event( _LOGGER.debug("Update filter on event: %s", event) self._update_filter_sensor_state(event.data["new_state"]) - def update(self): + def update(self) -> None: """Update TimeSMAFilter value.""" - _LOGGER.debug("Update filter: %s", self._state) - self._update_filter_sensor_state(self.hass.states.get(self._entity)) + temp_state = _State(dt_util.now(), 0) + self._run_filters(temp_state, timed_update=True) @callback def _update_filter_sensor_state( @@ -269,23 +267,10 @@ def _update_filter_sensor_state( self.async_write_ha_state() return - self._attr_available = True - temp_state = _State(new_state.last_updated, new_state.state) try: - for filt in self._filters: - filtered_state = filt.filter_state(copy(temp_state)) - _LOGGER.debug( - "%s(%s=%s) -> %s", - filt.name, - self._entity, - temp_state.state, - "skip" if filt.skip_processing else filtered_state.state, - ) - if filt.skip_processing: - return - temp_state = filtered_state + self._run_filters(temp_state) except ValueError: _LOGGER.error( "Could not convert state: %s (%s) to number", @@ -294,8 +279,6 @@ def _update_filter_sensor_state( ) return - self._state = temp_state.state - self._attr_icon = new_state.attributes.get(ATTR_ICON, ICON) self._attr_device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS) @@ -312,6 +295,24 @@ def _update_filter_sensor_state( if update_ha: self.async_write_ha_state() + def _run_filters(self, temp_state: _State, timed_update: bool = False) -> None: + self._attr_available = True + + for filt in self._filters: + filtered_state = filt.filter_state(copy(temp_state), timed_update) + _LOGGER.debug( + "%s(%s=%s) -> %s", + filt.name, + self._entity, + temp_state.state, + "skip" if filt.skip_processing else filtered_state.state, + ) + if filt.skip_processing: + return + temp_state = filtered_state + + self._state = temp_state.state + async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -401,9 +402,10 @@ class FilterState: state: str | float | int - def __init__(self, state: _State) -> None: + def __init__(self, state: _State, timed_update: bool = False) -> None: """Initialize with HA State object.""" self.timestamp = state.last_updated + self.timed_update = timed_update try: self.state = float(state.state) except ValueError: @@ -489,9 +491,9 @@ def _filter_state(self, new_state: FilterState) -> FilterState: """Implement filter.""" raise NotImplementedError - def filter_state(self, new_state: _State) -> _State: + def filter_state(self, new_state: _State, timed_update: bool = False) -> _State: """Implement a common interface for filters.""" - fstate = FilterState(new_state) + fstate = FilterState(new_state, timed_update) if self._only_numbers and not isinstance(fstate.state, Number): raise ValueError(f"State <{fstate.state}> is not a Number") @@ -499,7 +501,7 @@ def filter_state(self, new_state: _State) -> _State: filtered.set_precision(self.filter_precision) if self._store_raw: - self.states.append(copy(FilterState(new_state))) + self.states.append(copy(FilterState(new_state, timed_update))) else: self.states.append(copy(filtered)) new_state.state = filtered.state @@ -679,6 +681,7 @@ def __init__( def _leak(self, left_boundary: datetime) -> None: """Remove timeouted elements.""" + _LOGGER.debug("Leaking %s", self.queue) while self.queue: if self.queue[0].timestamp + self._time_window <= left_boundary: self.last_leak = self.queue.popleft() @@ -689,7 +692,9 @@ def _filter_state(self, new_state: FilterState) -> FilterState: """Implement the Simple Moving Average filter.""" self._leak(new_state.timestamp) - self.queue.append(copy(new_state)) + if new_state.timed_update is False: + self.queue.append(copy(new_state)) + _LOGGER.debug("Current queue: %s", self.queue) moving_sum: float = 0 start = new_state.timestamp - self._time_window diff --git a/pyproject.toml b/pyproject.toml index 369f6f409212b..59304434947af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -443,6 +443,9 @@ runtime-typing = false max-line-length-suggestions = 72 [tool.pytest.ini_options] +#log_cli = true +#log_cli_level = "DEBUG" + testpaths = [ "tests", ] diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index a3e0e58908a08..227b34b01d063 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant import config as hass_config @@ -34,7 +35,11 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import assert_setup_component, get_fixture_path +from tests.common import ( + assert_setup_component, + async_fire_time_changed, + get_fixture_path, +) @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -498,6 +503,48 @@ def test_time_sma(values: list[State]) -> None: assert filtered.state == 21.5 +async def test_time_sma_by_time(recorder_mock: Recorder, hass: HomeAssistant) -> None: + """Test if time_sma filter with update_by_timemworks.""" + + with freeze_time() as frozen_datetime: + + async def advance(seconds): + frozen_datetime.tick(seconds) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done(True) + + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "filter", + "name": "test", + "entity_id": "sensor.test_monitored", + "filters": [ + { + "filter": "time_simple_moving_average", + "window_size": timedelta(minutes=2), + "update_by_time": True, + }, + ], + } + }, + ) + await hass.async_block_till_done(True) + + for value in 20, 19, 18, 21, 22, 0: + await advance(60) + hass.states.async_set("sensor.test_monitored", value) + await hass.async_block_till_done(True) + + assert hass.states.get("sensor.test").state == "21.5" + await advance(30) + assert hass.states.get("sensor.test").state == "16.25" + await advance(30) + assert hass.states.get("sensor.test").state == "11.0" + + async def test_reload(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Verify we can reload filter sensors.""" hass.states.async_set("sensor.test_monitored", 12345) From 2158081b2fca1751dc159205774706b43336e452 Mon Sep 17 00:00:00 2001 From: Dave Brand Date: Wed, 11 Dec 2024 19:48:53 +0000 Subject: [PATCH 4/6] Remove stray log flags --- pyproject.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 59304434947af..369f6f409212b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -443,9 +443,6 @@ runtime-typing = false max-line-length-suggestions = 72 [tool.pytest.ini_options] -#log_cli = true -#log_cli_level = "DEBUG" - testpaths = [ "tests", ] From b80ab8da6a1576052e91687091153f08e95944f7 Mon Sep 17 00:00:00 2001 From: Dave Brand Date: Wed, 11 Dec 2024 19:51:20 +0000 Subject: [PATCH 5/6] Fix typo in test description --- tests/components/filter/test_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index 227b34b01d063..3f2d18cb4d6f1 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -504,7 +504,7 @@ def test_time_sma(values: list[State]) -> None: async def test_time_sma_by_time(recorder_mock: Recorder, hass: HomeAssistant) -> None: - """Test if time_sma filter with update_by_timemworks.""" + """Test if time_sma filter with update_by_time works.""" with freeze_time() as frozen_datetime: From 26d5abc139c1ac52e5bdace269069499b6614711 Mon Sep 17 00:00:00 2001 From: Dave Brand Date: Wed, 11 Dec 2024 19:58:10 +0000 Subject: [PATCH 6/6] Remove an unnecessary refactor --- homeassistant/components/filter/sensor.py | 48 ++++++++++++----------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 46fb16eeedfb8..c188eebb97f02 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -240,13 +240,18 @@ def _update_filter_sensor_state_event( self._update_filter_sensor_state(event.data["new_state"]) def update(self) -> None: - """Update TimeSMAFilter value.""" - temp_state = _State(dt_util.now(), 0) - self._run_filters(temp_state, timed_update=True) + """Time-based update of the filter without an input state change.""" + state = copy(self.hass.states.get(self.entity_id)) + if state is not None: + state.last_updated = dt_util.utcnow() + self._update_filter_sensor_state(state, update_ha=False, timed_update=True) @callback def _update_filter_sensor_state( - self, new_state: State | None, update_ha: bool = True + self, + new_state: State | None, + update_ha: bool = True, + timed_update: bool = False, ) -> None: """Process device state changes.""" if new_state is None: @@ -267,10 +272,23 @@ def _update_filter_sensor_state( self.async_write_ha_state() return + self._attr_available = True + temp_state = _State(new_state.last_updated, new_state.state) try: - self._run_filters(temp_state) + for filt in self._filters: + filtered_state = filt.filter_state(copy(temp_state), timed_update) + _LOGGER.debug( + "%s(%s=%s) -> %s", + filt.name, + self._entity, + temp_state.state, + "skip" if filt.skip_processing else filtered_state.state, + ) + if filt.skip_processing: + return + temp_state = filtered_state except ValueError: _LOGGER.error( "Could not convert state: %s (%s) to number", @@ -279,6 +297,8 @@ def _update_filter_sensor_state( ) return + self._state = temp_state.state + self._attr_icon = new_state.attributes.get(ATTR_ICON, ICON) self._attr_device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS) @@ -295,24 +315,6 @@ def _update_filter_sensor_state( if update_ha: self.async_write_ha_state() - def _run_filters(self, temp_state: _State, timed_update: bool = False) -> None: - self._attr_available = True - - for filt in self._filters: - filtered_state = filt.filter_state(copy(temp_state), timed_update) - _LOGGER.debug( - "%s(%s=%s) -> %s", - filt.name, - self._entity, - temp_state.state, - "skip" if filt.skip_processing else filtered_state.state, - ) - if filt.skip_processing: - return - temp_state = filtered_state - - self._state = temp_state.state - async def async_added_to_hass(self) -> None: """Register callbacks."""