From b5fa0b0e106033cf4af2d2bb83567ed4ba41ca75 Mon Sep 17 00:00:00 2001 From: Dave Brand Date: Wed, 27 Nov 2024 12:25:35 -0500 Subject: [PATCH] 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 | 59 +++++++++++++---------- pyproject.toml | 3 ++ tests/components/filter/test_sensor.py | 49 ++++++++++++++++++- 3 files changed, 84 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 1f7a91f26392a..df31732b253e0 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 @@ -724,6 +729,7 @@ def __init__( def _filter_state(self, new_state: FilterState) -> FilterState: """Implement the throttle filter.""" + if not self.states or len(self.states) == self.states.maxlen: self.states.clear() self._skip_processing = False @@ -753,6 +759,7 @@ def __init__( def _filter_state(self, new_state: FilterState) -> FilterState: """Implement the filter.""" + window_start = new_state.timestamp - self._time_window if not self._last_emitted_at or self._last_emitted_at <= window_start: self._last_emitted_at = new_state.timestamp diff --git a/pyproject.toml b/pyproject.toml index af910075b3226..a676b6905f82d 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)