diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 549d74ffd098f8..c188eebb97f026 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" @@ -129,6 +130,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, } ) @@ -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 getattr(filt, "update_by_time", False): + self._attr_should_poll = True + break + @callback def _update_filter_sensor_state_event( self, event: Event[EventStateChangedData] @@ -231,9 +239,19 @@ 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) -> None: + """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: @@ -260,7 +278,7 @@ def _update_filter_sensor_state( try: for filt in self._filters: - filtered_state = filt.filter_state(copy(temp_state)) + filtered_state = filt.filter_state(copy(temp_state), timed_update) _LOGGER.debug( "%s(%s=%s) -> %s", filt.name, @@ -386,9 +404,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: @@ -474,9 +493,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") @@ -484,7 +503,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 @@ -492,7 +511,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 +570,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 +620,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 +654,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. @@ -648,6 +667,7 @@ def __init__( entity: str, type: str, # pylint: disable=redefined-builtin precision: int = DEFAULT_PRECISION, + update_by_time: bool = False, ) -> None: """Initialize Filter. @@ -657,11 +677,13 @@ 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]() 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() @@ -672,7 +694,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 @@ -690,7 +714,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 +741,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. diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index a3e0e58908a080..3f2d18cb4d6f15 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_time works.""" + + 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)