Skip to content

Commit

Permalink
Fix and add test for new update_by_time flag
Browse files Browse the repository at this point in the history
Note: Since we can't set SCAN_INTERVAL per filter, I'm just letting it default.
  • Loading branch information
dbrand666 committed Dec 5, 2024
1 parent 9684e1a commit b5fa0b0
Show file tree
Hide file tree
Showing 3 changed files with 84 additions and 27 deletions.
59 changes: 33 additions & 26 deletions homeassistant/components/filter/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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",
Expand All @@ -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)
Expand All @@ -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."""

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -489,17 +491,17 @@ 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")

filtered = self._filter_state(fstate)
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
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down
49 changes: 48 additions & 1 deletion tests/components/filter/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit b5fa0b0

Please sign in to comment.