Filter Sensor (#12650)
* filter sensor platform implementation
* added tests
* default arguments
* Fix for unavailable units during initial startup
* unused variable
* Addresses code review by @MartinHjelmare
* fix
* don't need hass in this test
* Various Improvements
* Added Throttle Filter
* hound fixes
* test throttle filter
* fix
* Address comments by @balloob
* added test, reformulated filter tests
* Precision handling
* address comments from @balloob
* Revert "Precision handling"
This reverts commit f4abdd3702
.
* removed stats
* only round floats
* Registry decorator usage
* Tries to address remaining comments
pull/12825/head
parent
b9d8789771
commit
de3c76983a
|
@ -0,0 +1,299 @@
|
|||
"""
|
||||
Allows the creation of a sensor that filters state property.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/sensor.filter/
|
||||
"""
|
||||
import logging
|
||||
import statistics
|
||||
from collections import deque, Counter
|
||||
from numbers import Number
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.components.sensor import PLATFORM_SCHEMA
|
||||
from homeassistant.const import (
|
||||
CONF_NAME, CONF_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, ATTR_ENTITY_ID,
|
||||
ATTR_ICON, STATE_UNKNOWN, STATE_UNAVAILABLE)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.util.decorator import Registry
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.event import async_track_state_change
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FILTER_NAME_LOWPASS = 'lowpass'
|
||||
FILTER_NAME_OUTLIER = 'outlier'
|
||||
FILTER_NAME_THROTTLE = 'throttle'
|
||||
FILTERS = Registry()
|
||||
|
||||
CONF_FILTERS = 'filters'
|
||||
CONF_FILTER_NAME = 'filter'
|
||||
CONF_FILTER_WINDOW_SIZE = 'window_size'
|
||||
CONF_FILTER_PRECISION = 'precision'
|
||||
CONF_FILTER_RADIUS = 'radius'
|
||||
CONF_FILTER_TIME_CONSTANT = 'time_constant'
|
||||
|
||||
DEFAULT_WINDOW_SIZE = 1
|
||||
DEFAULT_PRECISION = 2
|
||||
DEFAULT_FILTER_RADIUS = 2.0
|
||||
DEFAULT_FILTER_TIME_CONSTANT = 10
|
||||
|
||||
NAME_TEMPLATE = "{} filter"
|
||||
ICON = 'mdi:chart-line-variant'
|
||||
|
||||
FILTER_SCHEMA = vol.Schema({
|
||||
vol.Optional(CONF_FILTER_WINDOW_SIZE,
|
||||
default=DEFAULT_WINDOW_SIZE): vol.Coerce(int),
|
||||
vol.Optional(CONF_FILTER_PRECISION,
|
||||
default=DEFAULT_PRECISION): vol.Coerce(int),
|
||||
})
|
||||
|
||||
FILTER_OUTLIER_SCHEMA = FILTER_SCHEMA.extend({
|
||||
vol.Required(CONF_FILTER_NAME): FILTER_NAME_OUTLIER,
|
||||
vol.Optional(CONF_FILTER_RADIUS,
|
||||
default=DEFAULT_FILTER_RADIUS): vol.Coerce(float),
|
||||
})
|
||||
|
||||
FILTER_LOWPASS_SCHEMA = FILTER_SCHEMA.extend({
|
||||
vol.Required(CONF_FILTER_NAME): FILTER_NAME_LOWPASS,
|
||||
vol.Optional(CONF_FILTER_TIME_CONSTANT,
|
||||
default=DEFAULT_FILTER_TIME_CONSTANT): vol.Coerce(int),
|
||||
})
|
||||
|
||||
FILTER_THROTTLE_SCHEMA = FILTER_SCHEMA.extend({
|
||||
vol.Required(CONF_FILTER_NAME): FILTER_NAME_THROTTLE,
|
||||
})
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Required(CONF_FILTERS): vol.All(cv.ensure_list,
|
||||
[vol.Any(FILTER_OUTLIER_SCHEMA,
|
||||
FILTER_LOWPASS_SCHEMA,
|
||||
FILTER_THROTTLE_SCHEMA)])
|
||||
})
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up the template sensors."""
|
||||
name = config.get(CONF_NAME)
|
||||
entity_id = config.get(CONF_ENTITY_ID)
|
||||
|
||||
filters = [FILTERS[_filter.pop(CONF_FILTER_NAME)](
|
||||
entity=entity_id, **_filter)
|
||||
for _filter in config[CONF_FILTERS]]
|
||||
|
||||
async_add_devices([SensorFilter(name, entity_id, filters)])
|
||||
|
||||
|
||||
class SensorFilter(Entity):
|
||||
"""Representation of a Filter Sensor."""
|
||||
|
||||
def __init__(self, name, entity_id, filters):
|
||||
"""Initialize the sensor."""
|
||||
self._name = name
|
||||
self._entity = entity_id
|
||||
self._unit_of_measurement = None
|
||||
self._state = None
|
||||
self._filters = filters
|
||||
self._icon = None
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Register callbacks."""
|
||||
@callback
|
||||
def filter_sensor_state_listener(entity, old_state, new_state):
|
||||
"""Handle device state changes."""
|
||||
if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]:
|
||||
return
|
||||
|
||||
temp_state = new_state.state
|
||||
|
||||
try:
|
||||
for filt in self._filters:
|
||||
filtered_state = filt.filter_state(temp_state)
|
||||
_LOGGER.debug("%s(%s=%s) -> %s", filt.name,
|
||||
self._entity,
|
||||
temp_state,
|
||||
"skip" if filt.skip_processing else
|
||||
filtered_state)
|
||||
if filt.skip_processing:
|
||||
return
|
||||
temp_state = filtered_state
|
||||
except ValueError:
|
||||
_LOGGER.error("Could not convert state: %s to number",
|
||||
self._state)
|
||||
return
|
||||
|
||||
self._state = temp_state
|
||||
|
||||
if self._icon is None:
|
||||
self._icon = new_state.attributes.get(
|
||||
ATTR_ICON, ICON)
|
||||
|
||||
if self._unit_of_measurement is None:
|
||||
self._unit_of_measurement = new_state.attributes.get(
|
||||
ATTR_UNIT_OF_MEASUREMENT)
|
||||
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
async_track_state_change(
|
||||
self.hass, self._entity, filter_sensor_state_listener)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the sensor."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon to use in the frontend, if any."""
|
||||
return self._icon
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
"""Return the unit_of_measurement of the device."""
|
||||
return self._unit_of_measurement
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No polling needed."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes of the sensor."""
|
||||
state_attr = {
|
||||
ATTR_ENTITY_ID: self._entity
|
||||
}
|
||||
return state_attr
|
||||
|
||||
|
||||
class Filter(object):
|
||||
"""Filter skeleton.
|
||||
|
||||
Args:
|
||||
window_size (int): size of the sliding window that holds previous
|
||||
values
|
||||
precision (int): round filtered value to precision value
|
||||
entity (string): used for debugging only
|
||||
"""
|
||||
|
||||
def __init__(self, name, window_size=1, precision=None, entity=None):
|
||||
"""Initialize common attributes."""
|
||||
self.states = deque(maxlen=window_size)
|
||||
self.precision = precision
|
||||
self._name = name
|
||||
self._entity = entity
|
||||
self._skip_processing = False
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return filter name."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def skip_processing(self):
|
||||
"""Return wether the current filter_state should be skipped."""
|
||||
return self._skip_processing
|
||||
|
||||
def _filter_state(self, new_state):
|
||||
"""Implement filter."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def filter_state(self, new_state):
|
||||
"""Implement a common interface for filters."""
|
||||
filtered = self._filter_state(new_state)
|
||||
if isinstance(filtered, Number):
|
||||
filtered = round(float(filtered), self.precision)
|
||||
self.states.append(filtered)
|
||||
return filtered
|
||||
|
||||
|
||||
@FILTERS.register(FILTER_NAME_OUTLIER)
|
||||
class OutlierFilter(Filter):
|
||||
"""BASIC outlier filter.
|
||||
|
||||
Determines if new state is in a band around the median.
|
||||
|
||||
Args:
|
||||
radius (float): band radius
|
||||
"""
|
||||
|
||||
def __init__(self, window_size, precision, entity, radius):
|
||||
"""Initialize Filter."""
|
||||
super().__init__(FILTER_NAME_OUTLIER, window_size, precision, entity)
|
||||
self._radius = radius
|
||||
self._stats_internal = Counter()
|
||||
|
||||
def _filter_state(self, new_state):
|
||||
"""Implement the outlier filter."""
|
||||
new_state = float(new_state)
|
||||
|
||||
if (self.states and
|
||||
abs(new_state - statistics.median(self.states))
|
||||
> self._radius):
|
||||
|
||||
self._stats_internal['erasures'] += 1
|
||||
|
||||
_LOGGER.debug("Outlier nr. %s in %s: %s",
|
||||
self._stats_internal['erasures'],
|
||||
self._entity, new_state)
|
||||
return self.states[-1]
|
||||
return new_state
|
||||
|
||||
|
||||
@FILTERS.register(FILTER_NAME_LOWPASS)
|
||||
class LowPassFilter(Filter):
|
||||
"""BASIC Low Pass Filter.
|
||||
|
||||
Args:
|
||||
time_constant (int): time constant.
|
||||
"""
|
||||
|
||||
def __init__(self, window_size, precision, entity, time_constant):
|
||||
"""Initialize Filter."""
|
||||
super().__init__(FILTER_NAME_LOWPASS, window_size, precision, entity)
|
||||
self._time_constant = time_constant
|
||||
|
||||
def _filter_state(self, new_state):
|
||||
"""Implement the low pass filter."""
|
||||
new_state = float(new_state)
|
||||
|
||||
if not self.states:
|
||||
return new_state
|
||||
|
||||
new_weight = 1.0 / self._time_constant
|
||||
prev_weight = 1.0 - new_weight
|
||||
filtered = prev_weight * self.states[-1] + new_weight * new_state
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
@FILTERS.register(FILTER_NAME_THROTTLE)
|
||||
class ThrottleFilter(Filter):
|
||||
"""Throttle Filter.
|
||||
|
||||
One sample per window.
|
||||
"""
|
||||
|
||||
def __init__(self, window_size, precision, entity):
|
||||
"""Initialize Filter."""
|
||||
super().__init__(FILTER_NAME_THROTTLE, window_size, precision, entity)
|
||||
|
||||
def _filter_state(self, new_state):
|
||||
"""Implement the throttle filter."""
|
||||
if not self.states or len(self.states) == self.states.maxlen:
|
||||
self.states.clear()
|
||||
self._skip_processing = False
|
||||
else:
|
||||
self._skip_processing = True
|
||||
|
||||
return new_state
|
|
@ -0,0 +1,92 @@
|
|||
"""The test for the data filter sensor platform."""
|
||||
import unittest
|
||||
|
||||
from homeassistant.components.sensor.filter import (
|
||||
LowPassFilter, OutlierFilter, ThrottleFilter)
|
||||
from homeassistant.setup import setup_component
|
||||
from tests.common import get_test_home_assistant, assert_setup_component
|
||||
|
||||
|
||||
class TestFilterSensor(unittest.TestCase):
|
||||
"""Test the Data Filter sensor."""
|
||||
|
||||
def setup_method(self, method):
|
||||
"""Setup things to be run when tests are started."""
|
||||
self.hass = get_test_home_assistant()
|
||||
self.values = [20, 19, 18, 21, 22, 0]
|
||||
|
||||
def teardown_method(self, method):
|
||||
"""Stop everything that was started."""
|
||||
self.hass.stop()
|
||||
|
||||
def test_setup_fail(self):
|
||||
"""Test if filter doesn't exist."""
|
||||
config = {
|
||||
'sensor': {
|
||||
'platform': 'filter',
|
||||
'entity_id': 'sensor.test_monitored',
|
||||
'filters': [{'filter': 'nonexisting'}]
|
||||
}
|
||||
}
|
||||
with assert_setup_component(0):
|
||||
assert setup_component(self.hass, 'sensor', config)
|
||||
|
||||
def test_chain(self):
|
||||
"""Test if filter chaining works."""
|
||||
config = {
|
||||
'sensor': {
|
||||
'platform': 'filter',
|
||||
'name': 'test',
|
||||
'entity_id': 'sensor.test_monitored',
|
||||
'filters': [{
|
||||
'filter': 'outlier',
|
||||
'radius': 4.0
|
||||
}, {
|
||||
'filter': 'lowpass',
|
||||
'window_size': 4,
|
||||
'time_constant': 10,
|
||||
'precision': 2
|
||||
}]
|
||||
}
|
||||
}
|
||||
with assert_setup_component(1):
|
||||
assert setup_component(self.hass, 'sensor', config)
|
||||
|
||||
for value in self.values:
|
||||
self.hass.states.set(config['sensor']['entity_id'], value)
|
||||
self.hass.block_till_done()
|
||||
|
||||
state = self.hass.states.get('sensor.test')
|
||||
self.assertEqual('20.25', state.state)
|
||||
|
||||
def test_outlier(self):
|
||||
"""Test if outlier filter works."""
|
||||
filt = OutlierFilter(window_size=10,
|
||||
precision=2,
|
||||
entity=None,
|
||||
radius=4.0)
|
||||
for state in self.values:
|
||||
filtered = filt.filter_state(state)
|
||||
self.assertEqual(22, filtered)
|
||||
|
||||
def test_lowpass(self):
|
||||
"""Test if lowpass filter works."""
|
||||
filt = LowPassFilter(window_size=10,
|
||||
precision=2,
|
||||
entity=None,
|
||||
time_constant=10)
|
||||
for state in self.values:
|
||||
filtered = filt.filter_state(state)
|
||||
self.assertEqual(18.05, filtered)
|
||||
|
||||
def test_throttle(self):
|
||||
"""Test if lowpass filter works."""
|
||||
filt = ThrottleFilter(window_size=3,
|
||||
precision=2,
|
||||
entity=None)
|
||||
filtered = []
|
||||
for state in self.values:
|
||||
new_state = filt.filter_state(state)
|
||||
if not filt.skip_processing:
|
||||
filtered.append(new_state)
|
||||
self.assertEqual([20, 21], filtered)
|
Loading…
Reference in New Issue