From 6fc580810f386abe9794327b5d2607f125ff7a35 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 25 Feb 2018 00:46:48 +0000 Subject: [PATCH 01/22] filter sensor platform implementation --- homeassistant/components/sensor/filter.py | 270 ++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 homeassistant/components/sensor/filter.py diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py new file mode 100644 index 00000000000000..f488717351328e --- /dev/null +++ b/homeassistant/components/sensor/filter.py @@ -0,0 +1,270 @@ +""" +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 asyncio +import logging +import statistics +from collections import deque + +import voluptuous as vol + +from homeassistant.util import slugify +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, + EVENT_HOMEASSISTANT_START) +import homeassistant.helpers.config_validation as cv +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' + +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' + +NAME_TEMPLATE = "{} filter" +ICON = 'mdi: chart-line-variant' + +FILTER_SCHEMA = vol.Schema({ + vol.Optional(CONF_FILTER_WINDOW_SIZE): vol.Coerce(int), + vol.Optional(CONF_FILTER_PRECISION): vol.Coerce(int), +}) + +FILTER_OUTLIER_SCHEMA = FILTER_SCHEMA.extend({ + vol.Required(CONF_FILTER_NAME): FILTER_NAME_OUTLIER, + vol.Optional(CONF_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): vol.Coerce(int), +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, + vol.Required(CONF_FILTERS): [vol.Any(FILTER_OUTLIER_SCHEMA, + FILTER_LOWPASS_SCHEMA)] +}) + + +@asyncio.coroutine +# pylint: disable=unused-argument +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the template sensors.""" + sensors = [] + + name = config.get(CONF_NAME) + entity_id = config.get(CONF_ENTITY_ID) + filters = [] + + for _filter in config.get(CONF_FILTERS): + window_size = _filter.get(CONF_FILTER_WINDOW_SIZE) + precision = _filter.get(CONF_FILTER_PRECISION) + + if _filter['filter'] == FILTER_NAME_OUTLIER: + radius = _filter.get(CONF_FILTER_RADIUS) + filters.append(OutlierFilter(window_size=window_size, + precision=precision, + radius=radius)) + elif _filter['filter'] == FILTER_NAME_LOWPASS: + time_constant = _filter.get(CONF_FILTER_TIME_CONSTANT) + filters.append(LowPassFilter(window_size=window_size, + precision=precision, + time_constant=time_constant)) + + sensors.append(SensorFilter(hass, name, entity_id, filters)) + + async_add_devices(sensors) + + +class SensorFilter(Entity): + """Representation of a Filter Sensor.""" + + filters = [FILTER_NAME_LOWPASS, FILTER_NAME_OUTLIER] + + def __init__(self, hass, name, entity_id, filters): + """Initialize the sensor.""" + self.hass = hass + self._name = name + self._entity = entity_id + self._unit_of_measurement = None + self._state = None + self._filters = filters + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + @callback + def filter_sensor_state_listener(entity, old_state, new_state): + """Handle device state changes.""" + self._unit_of_measurement = new_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT) + + self._state = new_state.state + for filt in self._filters: + filtered_state = filt.filter_state(self._state) + _LOGGER.debug("%s(%s) -> %s", filt.name, self._state, + filtered_state) + self._state = filtered_state + filt.states.append(filtered_state) + + self.async_schedule_update_ha_state(True) + + @callback + def filter_sensor_startup(event): + """Update filter on startup.""" + async_track_state_change( + self.hass, self._entity, filter_sensor_state_listener) + + self.async_schedule_update_ha_state(True) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, filter_sensor_startup) + + @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 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_UNIT_OF_MEASUREMENT: self._unit_of_measurement, + ATTR_ENTITY_ID: self._entity + } + for filt in self._filters: + state_attr.update({ + slugify("{} stats".format(filt.name)): filt.stats + }) + + return state_attr + + +class Filter(object): + """Filter skeleton. + + Args: + window_size (int): size of the sliding window that holds previous + values + """ + + def __init__(self, name, window_size=1, precision=None): + """Initialize common properties.""" + self.states = deque(maxlen=window_size) + self.precision = precision + self._stats = dict() + self._name = name + + @property + def name(self): + """Return filter name.""" + return self._name + + @property + def stats(self): + """Return statistics of the filter.""" + return self._stats + + 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 self.precision is None: + return filtered + return round(filtered, self.precision) + + +class OutlierFilter(Filter): + """BASIC outlier filter. + + Determines if new state is in a band around the median + + Args: + radius (float): band radius + window_size (int): see Filter() + """ + + def __init__(self, window_size, precision, radius=2.0): + """Initialize Filter.""" + super().__init__(FILTER_NAME_OUTLIER, window_size, precision) + self._radius = radius + + def _filter_state(self, new_state): + """Implement the outlier filter.""" + new_state = float(new_state) + if (len(self.states) > 1 and + abs(new_state - statistics.median(self.states)) + > self._radius): + + erasures = self._stats.get('erasures', 0) + self._stats['erasures'] = erasures+1 + + _LOGGER.warning("Outlier in %s: %s", self._name, new_state) + return self.states[-1] + return new_state + + +class LowPassFilter(Filter): + """BASIC Low Pass Filter. + + Args: + time_constant (int): time constant. + window_size (int): see Filter() + """ + + def __init__(self, window_size, precision, time_constant=10): + """Initialize Filter.""" + super().__init__(FILTER_NAME_LOWPASS, window_size, precision) + self._time_constant = time_constant + + def _filter_state(self, new_state): + """Implement the low pass filter.""" + new_state = float(new_state) + + try: + new_weight = 1.0 / self._time_constant + prev_weight = 1.0 - new_weight + filtered = prev_weight * self.states[-1] + new_weight * new_state + except IndexError: + # if we don't have enough states to run the filter + # just accept the new value + filtered = new_state + + return filtered From 13c6c64cded448341de85b9899888e13fded0d29 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 25 Feb 2018 01:20:49 +0000 Subject: [PATCH 02/22] added tests --- tests/components/sensor/test_filter.py | 86 ++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/components/sensor/test_filter.py diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py new file mode 100644 index 00000000000000..8f7242c176110c --- /dev/null +++ b/tests/components/sensor/test_filter.py @@ -0,0 +1,86 @@ +"""The test for the data filter sensor platform.""" +import unittest + +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) + + self.hass.start() + self.hass.block_till_done() + + def test_outlier(self): + """Test if filter outlier works.""" + config = { + 'sensor': { + 'platform': 'filter', + 'name': 'test', + 'entity_id': 'sensor.test_monitored', + 'filters': [{ + 'filter': 'outlier', + 'radius': 4.0 + }] + } + } + with assert_setup_component(1): + assert setup_component(self.hass, 'sensor', config) + + self.hass.start() + self.hass.block_till_done() + + 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('22.0', state.state) + + def test_lowpass(self): + """Test if filter lowpass works.""" + config = { + 'sensor': { + 'platform': 'filter', + 'name': 'test', + 'entity_id': 'sensor.test_monitored', + 'filters': [{ + 'filter': 'lowpass', + 'time_constant': 10, + 'precision': 2 + }] + } + } + with assert_setup_component(1): + assert setup_component(self.hass, 'sensor', config) + + self.hass.start() + self.hass.block_till_done() + + 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(18.05, round(float(state.state), 2)) From f489bff58289fb095695114892780c9dd618bba4 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 25 Feb 2018 01:21:35 +0000 Subject: [PATCH 03/22] default arguments --- homeassistant/components/sensor/filter.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index f488717351328e..6bf258f4b7a667 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -33,6 +33,9 @@ CONF_FILTER_RADIUS = 'radius' CONF_FILTER_TIME_CONSTANT = 'time_constant' +DEFAULT_FILTER_RADIUS = 2.0 +DEFAULT_FILTER_TIME_CONSTANT = 10 + NAME_TEMPLATE = "{} filter" ICON = 'mdi: chart-line-variant' @@ -75,12 +78,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): precision = _filter.get(CONF_FILTER_PRECISION) if _filter['filter'] == FILTER_NAME_OUTLIER: - radius = _filter.get(CONF_FILTER_RADIUS) + radius = _filter.get(CONF_FILTER_RADIUS, DEFAULT_FILTER_RADIUS) filters.append(OutlierFilter(window_size=window_size, precision=precision, radius=radius)) elif _filter['filter'] == FILTER_NAME_LOWPASS: - time_constant = _filter.get(CONF_FILTER_TIME_CONSTANT) + time_constant = _filter.get(CONF_FILTER_TIME_CONSTANT, + DEFAULT_FILTER_TIME_CONSTANT) filters.append(LowPassFilter(window_size=window_size, precision=precision, time_constant=time_constant)) @@ -221,7 +225,7 @@ class OutlierFilter(Filter): window_size (int): see Filter() """ - def __init__(self, window_size, precision, radius=2.0): + def __init__(self, window_size, precision, radius): """Initialize Filter.""" super().__init__(FILTER_NAME_OUTLIER, window_size, precision) self._radius = radius @@ -249,7 +253,7 @@ class LowPassFilter(Filter): window_size (int): see Filter() """ - def __init__(self, window_size, precision, time_constant=10): + def __init__(self, window_size, precision, time_constant): """Initialize Filter.""" super().__init__(FILTER_NAME_LOWPASS, window_size, precision) self._time_constant = time_constant From 865b866c043a585f4dfced1d99e4cbc206afbdf1 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 25 Feb 2018 02:07:14 +0000 Subject: [PATCH 04/22] Fix for unavailable units during initial startup --- homeassistant/components/sensor/filter.py | 26 +++++++++-------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 6bf258f4b7a667..8e3cda2b7c3073 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -119,24 +119,19 @@ def filter_sensor_state_listener(entity, old_state, new_state): self._state = new_state.state for filt in self._filters: - filtered_state = filt.filter_state(self._state) - _LOGGER.debug("%s(%s) -> %s", filt.name, self._state, - filtered_state) - self._state = filtered_state - filt.states.append(filtered_state) + try: + filtered_state = filt.filter_state(self._state) + _LOGGER.debug("%s(%s) -> %s", filt.name, self._state, + filtered_state) + self._state = filtered_state + filt.states.append(filtered_state) + except ValueError: + _LOGGER.warning("Could not convert state to number") self.async_schedule_update_ha_state(True) - @callback - def filter_sensor_startup(event): - """Update filter on startup.""" - async_track_state_change( - self.hass, self._entity, filter_sensor_state_listener) - - self.async_schedule_update_ha_state(True) - - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, filter_sensor_startup) + async_track_state_change( + self.hass, self._entity, filter_sensor_state_listener) @property def name(self): @@ -167,7 +162,6 @@ def should_poll(self): def device_state_attributes(self): """Return the state attributes of the sensor.""" state_attr = { - ATTR_UNIT_OF_MEASUREMENT: self._unit_of_measurement, ATTR_ENTITY_ID: self._entity } for filt in self._filters: From 70124ce25e471b3bfbddd2c757407d0e5a48371d Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 25 Feb 2018 02:08:53 +0000 Subject: [PATCH 05/22] unused variable --- homeassistant/components/sensor/filter.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 8e3cda2b7c3073..40b37a40d0f2f4 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -15,8 +15,7 @@ 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, - EVENT_HOMEASSISTANT_START) + CONF_NAME, CONF_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, ATTR_ENTITY_ID) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change From 74ae5208741f11c327a89cae81777f5162812887 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 25 Feb 2018 13:01:40 +0000 Subject: [PATCH 06/22] Addresses code review by @MartinHjelmare --- homeassistant/components/sensor/filter.py | 42 +++++++++++------------ 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 40b37a40d0f2f4..1bfdfcfda3bf1a 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -45,25 +45,26 @@ FILTER_OUTLIER_SCHEMA = FILTER_SCHEMA.extend({ vol.Required(CONF_FILTER_NAME): FILTER_NAME_OUTLIER, - vol.Optional(CONF_FILTER_RADIUS): vol.Coerce(float), + 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): vol.Coerce(int), + vol.Optional(CONF_FILTER_TIME_CONSTANT, + default=DEFAULT_FILTER_TIME_CONSTANT): vol.Coerce(int), }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(ATTR_ENTITY_ID): cv.entity_id, vol.Optional(CONF_NAME): cv.string, - vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, - vol.Required(CONF_FILTERS): [vol.Any(FILTER_OUTLIER_SCHEMA, - FILTER_LOWPASS_SCHEMA)] + vol.Required(CONF_FILTERS): vol.All(cv.ensure_list, + [vol.Any(FILTER_OUTLIER_SCHEMA, + FILTER_LOWPASS_SCHEMA)]) }) @asyncio.coroutine -# pylint: disable=unused-argument def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the template sensors.""" sensors = [] @@ -72,23 +73,22 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): entity_id = config.get(CONF_ENTITY_ID) filters = [] - for _filter in config.get(CONF_FILTERS): + for _filter in config[CONF_FILTERS]: window_size = _filter.get(CONF_FILTER_WINDOW_SIZE) precision = _filter.get(CONF_FILTER_PRECISION) - if _filter['filter'] == FILTER_NAME_OUTLIER: - radius = _filter.get(CONF_FILTER_RADIUS, DEFAULT_FILTER_RADIUS) + if _filter[CONF_FILTER_NAME] == FILTER_NAME_OUTLIER: + radius = _filter.get(CONF_FILTER_RADIUS) filters.append(OutlierFilter(window_size=window_size, precision=precision, radius=radius)) - elif _filter['filter'] == FILTER_NAME_LOWPASS: - time_constant = _filter.get(CONF_FILTER_TIME_CONSTANT, - DEFAULT_FILTER_TIME_CONSTANT) + elif _filter[CONF_FILTER_NAME] == FILTER_NAME_LOWPASS: + time_constant = _filter.get(CONF_FILTER_TIME_CONSTANT) filters.append(LowPassFilter(window_size=window_size, precision=precision, time_constant=time_constant)) - sensors.append(SensorFilter(hass, name, entity_id, filters)) + sensors.append(SensorFilter(name, entity_id, filters)) async_add_devices(sensors) @@ -96,11 +96,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class SensorFilter(Entity): """Representation of a Filter Sensor.""" - filters = [FILTER_NAME_LOWPASS, FILTER_NAME_OUTLIER] - - def __init__(self, hass, name, entity_id, filters): + def __init__(self, name, entity_id, filters): """Initialize the sensor.""" - self.hass = hass self._name = name self._entity = entity_id self._unit_of_measurement = None @@ -125,7 +122,8 @@ def filter_sensor_state_listener(entity, old_state, new_state): self._state = filtered_state filt.states.append(filtered_state) except ValueError: - _LOGGER.warning("Could not convert state to number") + _LOGGER.warning("Could not convert state: %s to number", + self._state) self.async_schedule_update_ha_state(True) @@ -180,10 +178,10 @@ class Filter(object): """ def __init__(self, name, window_size=1, precision=None): - """Initialize common properties.""" + """Initialize common attributes.""" self.states = deque(maxlen=window_size) self.precision = precision - self._stats = dict() + self._stats = {} self._name = name @property @@ -211,7 +209,7 @@ def filter_state(self, new_state): class OutlierFilter(Filter): """BASIC outlier filter. - Determines if new state is in a band around the median + Determines if new state is in a band around the median. Args: radius (float): band radius @@ -233,7 +231,7 @@ def _filter_state(self, new_state): erasures = self._stats.get('erasures', 0) self._stats['erasures'] = erasures+1 - _LOGGER.warning("Outlier in %s: %s", self._name, new_state) + _LOGGER.debug("Outlier in %s: %s", self._name, new_state) return self.states[-1] return new_state From c0a7f474ccbe3ab2621db4d3c21a7413615e358c Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 25 Feb 2018 13:03:08 +0000 Subject: [PATCH 07/22] fix --- homeassistant/components/sensor/filter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 1bfdfcfda3bf1a..1eae838797b997 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -56,7 +56,7 @@ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(ATTR_ENTITY_ID): cv.entity_id, + 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, From 81e7c3c383c2bded8e903c7af5b8fc20e8a230b7 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 25 Feb 2018 19:21:10 +0000 Subject: [PATCH 08/22] don't need hass in this test --- tests/components/sensor/test_filter.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index 8f7242c176110c..3f99eefd1cc6df 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -29,9 +29,6 @@ def test_setup_fail(self): with assert_setup_component(0): assert setup_component(self.hass, 'sensor', config) - self.hass.start() - self.hass.block_till_done() - def test_outlier(self): """Test if filter outlier works.""" config = { From 4b56d7e10a5c78d453ac54bc7723d3b2f75826c8 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 25 Feb 2018 21:53:27 +0000 Subject: [PATCH 09/22] Various Improvements --- homeassistant/components/sensor/filter.py | 63 +++++++++++++++-------- tests/components/sensor/test_filter.py | 6 --- 2 files changed, 41 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 1eae838797b997..5ae2e883c393dc 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -7,7 +7,7 @@ import asyncio import logging import statistics -from collections import deque +from collections import deque, Counter import voluptuous as vol @@ -15,7 +15,8 @@ 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) + CONF_NAME, CONF_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, ATTR_ENTITY_ID, + ATTR_ICON, STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change @@ -36,7 +37,7 @@ DEFAULT_FILTER_TIME_CONSTANT = 10 NAME_TEMPLATE = "{} filter" -ICON = 'mdi: chart-line-variant' +ICON = 'mdi:chart-line-variant' FILTER_SCHEMA = vol.Schema({ vol.Optional(CONF_FILTER_WINDOW_SIZE): vol.Coerce(int), @@ -67,8 +68,6 @@ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the template sensors.""" - sensors = [] - name = config.get(CONF_NAME) entity_id = config.get(CONF_ENTITY_ID) filters = [] @@ -81,16 +80,16 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): radius = _filter.get(CONF_FILTER_RADIUS) filters.append(OutlierFilter(window_size=window_size, precision=precision, + entity=entity_id, radius=radius)) elif _filter[CONF_FILTER_NAME] == FILTER_NAME_LOWPASS: time_constant = _filter.get(CONF_FILTER_TIME_CONSTANT) filters.append(LowPassFilter(window_size=window_size, precision=precision, + entity=entity_id, time_constant=time_constant)) - sensors.append(SensorFilter(name, entity_id, filters)) - - async_add_devices(sensors) + async_add_devices([SensorFilter(name, entity_id, filters)]) class SensorFilter(Entity): @@ -103,6 +102,7 @@ def __init__(self, name, entity_id, filters): self._unit_of_measurement = None self._state = None self._filters = filters + self._icon = ICON @asyncio.coroutine def async_added_to_hass(self): @@ -112,13 +112,18 @@ def filter_sensor_state_listener(entity, old_state, new_state): """Handle device state changes.""" self._unit_of_measurement = new_state.attributes.get( ATTR_UNIT_OF_MEASUREMENT) + self._icon = new_state.attributes.get(ATTR_ICON, ICON) self._state = new_state.state + + if self._state == STATE_UNKNOWN: + return + for filt in self._filters: try: filtered_state = filt.filter_state(self._state) - _LOGGER.debug("%s(%s) -> %s", filt.name, self._state, - filtered_state) + _LOGGER.debug("%s(%s, %s) -> %s", filt.name, self._entity, + self._state, filtered_state) self._state = filtered_state filt.states.append(filtered_state) except ValueError: @@ -143,7 +148,7 @@ def state(self): @property def icon(self): """Return the icon to use in the frontend, if any.""" - return ICON + return self._icon @property def unit_of_measurement(self): @@ -162,9 +167,13 @@ def device_state_attributes(self): ATTR_ENTITY_ID: self._entity } for filt in self._filters: - state_attr.update({ - slugify("{} stats".format(filt.name)): filt.stats - }) + for filt_stat_key, filt_stat_value in filt.stats.items(): + filt_stat = slugify("{} {}".format(filt.name, filt_stat_key)) + _LOGGER.debug("stats(%s): %s: %s", self._entity, + filt_stat, filt_stat_value) + state_attr.update({ + filt_stat: filt_stat_value + }) return state_attr @@ -175,14 +184,17 @@ class Filter(object): 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): + def __init__(self, name, window_size=1, precision=None, entity=None): """Initialize common attributes.""" self.states = deque(maxlen=window_size) self.precision = precision self._stats = {} self._name = name + self._entity = entity @property def name(self): @@ -216,22 +228,29 @@ class OutlierFilter(Filter): window_size (int): see Filter() """ - def __init__(self, window_size, precision, radius): + def __init__(self, window_size, precision, entity, radius): """Initialize Filter.""" - super().__init__(FILTER_NAME_OUTLIER, window_size, precision) + 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.""" + self._stats_internal['total_filtered'] += 1 new_state = float(new_state) + + self._stats['erasures'] = "{0:.2f}%".format( + 100 * self._stats_internal['erasures'] + / self._stats_internal['total_filtered'] + ) + if (len(self.states) > 1 and abs(new_state - statistics.median(self.states)) > self._radius): - erasures = self._stats.get('erasures', 0) - self._stats['erasures'] = erasures+1 + self._stats_internal['erasures'] += 1 - _LOGGER.debug("Outlier in %s: %s", self._name, new_state) + _LOGGER.debug("Outlier in %s: %s", self._entity, new_state) return self.states[-1] return new_state @@ -244,9 +263,9 @@ class LowPassFilter(Filter): window_size (int): see Filter() """ - def __init__(self, window_size, precision, time_constant): + def __init__(self, window_size, precision, entity, time_constant): """Initialize Filter.""" - super().__init__(FILTER_NAME_LOWPASS, window_size, precision) + super().__init__(FILTER_NAME_LOWPASS, window_size, precision, entity) self._time_constant = time_constant def _filter_state(self, new_state): diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index 3f99eefd1cc6df..327d782e94f647 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -45,9 +45,6 @@ def test_outlier(self): with assert_setup_component(1): assert setup_component(self.hass, 'sensor', config) - self.hass.start() - self.hass.block_till_done() - for value in self.values: self.hass.states.set(config['sensor']['entity_id'], value) self.hass.block_till_done() @@ -72,9 +69,6 @@ def test_lowpass(self): with assert_setup_component(1): assert setup_component(self.hass, 'sensor', config) - self.hass.start() - self.hass.block_till_done() - for value in self.values: self.hass.states.set(config['sensor']['entity_id'], value) self.hass.block_till_done() From 50da8ad7c18d5a96021f56149432ae6e5e94feb2 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 25 Feb 2018 23:46:16 +0000 Subject: [PATCH 10/22] Added Throttle Filter --- homeassistant/components/sensor/filter.py | 54 ++++++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 5ae2e883c393dc..bd63762ccb803b 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -25,6 +25,7 @@ FILTER_NAME_LOWPASS = 'lowpass' FILTER_NAME_OUTLIER = 'outlier' +FILTER_NAME_THROTTLE = 'throttle' CONF_FILTERS = 'filters' CONF_FILTER_NAME = 'filter' @@ -56,12 +57,17 @@ 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_LOWPASS_SCHEMA, + FILTER_THROTTLE_SCHEMA)]) }) @@ -88,6 +94,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): precision=precision, entity=entity_id, time_constant=time_constant)) + elif _filter[CONF_FILTER_NAME] == FILTER_NAME_THROTTLE: + filters.append(ThrottleFilter(window_size=window_size, + precision=precision, + entity=entity_id)) async_add_devices([SensorFilter(name, entity_id, filters)]) @@ -122,13 +132,18 @@ def filter_sensor_state_listener(entity, old_state, new_state): for filt in self._filters: try: filtered_state = filt.filter_state(self._state) - _LOGGER.debug("%s(%s, %s) -> %s", filt.name, self._entity, - self._state, filtered_state) - self._state = filtered_state - filt.states.append(filtered_state) + _LOGGER.debug("%s(%s, %s) -> %s", filt.name, + self._entity, + self._state, + "skip" if filt.skip else filtered_state) + if filt.skip: + return except ValueError: _LOGGER.warning("Could not convert state: %s to number", self._state) + finally: + self._state = filtered_state + filt.states.append(filtered_state) self.async_schedule_update_ha_state(True) @@ -195,12 +210,18 @@ def __init__(self, name, window_size=1, precision=None, entity=None): self._stats = {} self._name = name self._entity = entity + self._skip = False @property def name(self): """Return filter name.""" return self._name + @property + def skip(self): + """Return wether the current filter_state should be skipped.""" + return self._skip + @property def stats(self): """Return statistics of the filter.""" @@ -225,7 +246,6 @@ class OutlierFilter(Filter): Args: radius (float): band radius - window_size (int): see Filter() """ def __init__(self, window_size, precision, entity, radius): @@ -260,7 +280,6 @@ class LowPassFilter(Filter): Args: time_constant (int): time constant. - window_size (int): see Filter() """ def __init__(self, window_size, precision, entity, time_constant): @@ -282,3 +301,24 @@ def _filter_state(self, new_state): filtered = new_state return filtered + + +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 = False + else: + self._skip = True + + return new_state From 48a0ed00608d3f7399ccc70ddf197361553ed5d4 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 25 Feb 2018 23:47:57 +0000 Subject: [PATCH 11/22] hound fixes --- homeassistant/components/sensor/filter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index bd63762ccb803b..47a4184eeda72d 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -96,8 +96,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): time_constant=time_constant)) elif _filter[CONF_FILTER_NAME] == FILTER_NAME_THROTTLE: filters.append(ThrottleFilter(window_size=window_size, - precision=precision, - entity=entity_id)) + precision=precision, + entity=entity_id)) async_add_devices([SensorFilter(name, entity_id, filters)]) @@ -321,4 +321,4 @@ def _filter_state(self, new_state): else: self._skip = True - return new_state + return new_state From 7b5ffe228d2863e68e74ccf9d486359ff275fe17 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 26 Feb 2018 00:19:23 +0000 Subject: [PATCH 12/22] test throttle filter --- tests/components/sensor/test_filter.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index 327d782e94f647..37245b20df55aa 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -75,3 +75,26 @@ def test_lowpass(self): state = self.hass.states.get('sensor.test') self.assertEqual(18.05, round(float(state.state), 2)) + + def test_throttle(self): + """Test if filter throttle works.""" + config = { + 'sensor': { + 'platform': 'filter', + 'name': 'test', + 'entity_id': 'sensor.test_monitored', + 'filters': [{ + 'filter': 'throttle', + 'window_size': 3, + }] + } + } + 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('21', state.state) From 9154caf410b2fb258b312551d7adeb2adf6f3a15 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 26 Feb 2018 00:20:58 +0000 Subject: [PATCH 13/22] fix --- tests/components/sensor/test_filter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index 37245b20df55aa..7d712a5ea1ddd4 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -75,7 +75,7 @@ def test_lowpass(self): state = self.hass.states.get('sensor.test') self.assertEqual(18.05, round(float(state.state), 2)) - + def test_throttle(self): """Test if filter throttle works.""" config = { From 5a728c6078144bc939cbf088852e2dfd96f64a9b Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 26 Feb 2018 18:49:57 +0000 Subject: [PATCH 14/22] Address comments by @balloob --- homeassistant/components/sensor/filter.py | 38 ++++++++++------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 47a4184eeda72d..f401da37aef7dd 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.filter/ """ -import asyncio import logging import statistics from collections import deque, Counter @@ -16,7 +15,7 @@ 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) + ATTR_ICON, STATE_UNKNOWN, STATE_UNAVAILABLE) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change @@ -71,8 +70,8 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +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) @@ -114,19 +113,18 @@ def __init__(self, name, entity_id, filters): self._filters = filters self._icon = ICON - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Register callbacks.""" @callback def filter_sensor_state_listener(entity, old_state, new_state): """Handle device state changes.""" self._unit_of_measurement = new_state.attributes.get( ATTR_UNIT_OF_MEASUREMENT) - self._icon = new_state.attributes.get(ATTR_ICON, ICON) + self._icon = new_state.attributes.get(ATTR_ICON, self._icon) self._state = new_state.state - if self._state == STATE_UNKNOWN: + if self._state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: return for filt in self._filters: @@ -143,9 +141,8 @@ def filter_sensor_state_listener(entity, old_state, new_state): self._state) finally: self._state = filtered_state - filt.states.append(filtered_state) - self.async_schedule_update_ha_state(True) + self.async_schedule_update_ha_state() async_track_state_change( self.hass, self._entity, filter_sensor_state_listener) @@ -234,9 +231,10 @@ def _filter_state(self, new_state): def filter_state(self, new_state): """Implement a common interface for filters.""" filtered = self._filter_state(new_state) - if self.precision is None: - return filtered - return round(filtered, self.precision) + if self.precision is not None: + filtered = round(filtered, self.precision) + self.states.append(filtered) + return filtered class OutlierFilter(Filter): @@ -291,14 +289,12 @@ def _filter_state(self, new_state): """Implement the low pass filter.""" new_state = float(new_state) - try: - new_weight = 1.0 / self._time_constant - prev_weight = 1.0 - new_weight - filtered = prev_weight * self.states[-1] + new_weight * new_state - except IndexError: - # if we don't have enough states to run the filter - # just accept the new value - filtered = 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 From e4461aaf96303b2bafbf8c8800b7276d38bb14b0 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 26 Feb 2018 20:50:07 +0000 Subject: [PATCH 15/22] added test, reformulated filter tests --- tests/components/sensor/test_filter.py | 83 ++++++++++++-------------- 1 file changed, 37 insertions(+), 46 deletions(-) diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index 7d712a5ea1ddd4..bf0f57506540b5 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -1,6 +1,8 @@ """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 @@ -29,8 +31,8 @@ def test_setup_fail(self): with assert_setup_component(0): assert setup_component(self.hass, 'sensor', config) - def test_outlier(self): - """Test if filter outlier works.""" + def test_chain(self): + """Test if filter chaining works.""" config = { 'sensor': { 'platform': 'filter', @@ -39,6 +41,10 @@ def test_outlier(self): 'filters': [{ 'filter': 'outlier', 'radius': 4.0 + }, { + 'filter': 'lowpass', + 'time_constant': 10, + 'precision': 2 }] } } @@ -50,51 +56,36 @@ def test_outlier(self): self.hass.block_till_done() state = self.hass.states.get('sensor.test') - self.assertEqual('22.0', state.state) + self.assertEqual('20.25', state.state) - def test_lowpass(self): - """Test if filter lowpass works.""" - config = { - 'sensor': { - 'platform': 'filter', - 'name': 'test', - 'entity_id': 'sensor.test_monitored', - 'filters': [{ - 'filter': 'lowpass', - '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() + 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) - state = self.hass.states.get('sensor.test') - self.assertEqual(18.05, round(float(state.state), 2)) + 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 filter throttle works.""" - config = { - 'sensor': { - 'platform': 'filter', - 'name': 'test', - 'entity_id': 'sensor.test_monitored', - 'filters': [{ - 'filter': 'throttle', - 'window_size': 3, - }] - } - } - 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('21', state.state) + """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: + filtered.append(new_state) + self.assertEqual([20, 21], filtered) From f4abdd37021380d090b93a5c6b6f9e39cd59bc46 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 27 Feb 2018 22:00:37 +0000 Subject: [PATCH 16/22] Precision handling --- homeassistant/components/sensor/filter.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index f401da37aef7dd..198630b02e7667 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -232,7 +232,10 @@ def filter_state(self, new_state): """Implement a common interface for filters.""" filtered = self._filter_state(new_state) if self.precision is not None: - filtered = round(filtered, self.precision) + if self.precision < 1: + filtered = round(filtered / self.precision) * self.precision + else: + filtered = round(filtered, self.precision) self.states.append(filtered) return filtered From ddfcf76f987c856bc9d0eef2194b02699f448dcb Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 27 Feb 2018 22:24:28 +0000 Subject: [PATCH 17/22] address comments from @balloob --- homeassistant/components/sensor/filter.py | 18 +++++++++--------- tests/components/sensor/test_filter.py | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 198630b02e7667..6292ef60a6f950 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -10,7 +10,6 @@ import voluptuous as vol -from homeassistant.util import slugify from homeassistant.core import callback from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( @@ -133,8 +132,9 @@ def filter_sensor_state_listener(entity, old_state, new_state): _LOGGER.debug("%s(%s, %s) -> %s", filt.name, self._entity, self._state, - "skip" if filt.skip else filtered_state) - if filt.skip: + "skip" if filt.skip_processing else + filtered_state) + if filt.skip_processing: return except ValueError: _LOGGER.warning("Could not convert state: %s to number", @@ -180,7 +180,7 @@ def device_state_attributes(self): } for filt in self._filters: for filt_stat_key, filt_stat_value in filt.stats.items(): - filt_stat = slugify("{} {}".format(filt.name, filt_stat_key)) + filt_stat = "{}_{}".format(filt.name, filt_stat_key) _LOGGER.debug("stats(%s): %s: %s", self._entity, filt_stat, filt_stat_value) state_attr.update({ @@ -207,7 +207,7 @@ def __init__(self, name, window_size=1, precision=None, entity=None): self._stats = {} self._name = name self._entity = entity - self._skip = False + self._skip_processing = False @property def name(self): @@ -215,9 +215,9 @@ def name(self): return self._name @property - def skip(self): + def skip_processing(self): """Return wether the current filter_state should be skipped.""" - return self._skip + return self._skip_processing @property def stats(self): @@ -316,8 +316,8 @@ 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 = False + self._skip_processing = False else: - self._skip = True + self._skip_processing = True return new_state diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index bf0f57506540b5..0acedac5fa0ca7 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -86,6 +86,6 @@ def test_throttle(self): filtered = [] for state in self.values: new_state = filt.filter_state(state) - if not filt.skip: + if not filt.skip_processing: filtered.append(new_state) self.assertEqual([20, 21], filtered) From 7bf3edac4aec933bb823775ee01c2b58f0010d80 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 27 Feb 2018 22:35:48 +0000 Subject: [PATCH 18/22] Revert "Precision handling" This reverts commit f4abdd37021380d090b93a5c6b6f9e39cd59bc46. --- homeassistant/components/sensor/filter.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 6292ef60a6f950..dfbde20e5bd439 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -232,10 +232,7 @@ def filter_state(self, new_state): """Implement a common interface for filters.""" filtered = self._filter_state(new_state) if self.precision is not None: - if self.precision < 1: - filtered = round(filtered / self.precision) * self.precision - else: - filtered = round(filtered, self.precision) + filtered = round(filtered, self.precision) self.states.append(filtered) return filtered From fa232a0a04552755b00250e075dc96720fd2f300 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Wed, 28 Feb 2018 22:52:17 +0000 Subject: [PATCH 19/22] removed stats --- homeassistant/components/sensor/filter.py | 52 ++++++++--------------- tests/components/sensor/test_filter.py | 1 + 2 files changed, 19 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index dfbde20e5bd439..a020e299a9a894 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -32,6 +32,7 @@ CONF_FILTER_RADIUS = 'radius' CONF_FILTER_TIME_CONSTANT = 'time_constant' +DEFAULT_WINDOW_SIZE = 1 DEFAULT_FILTER_RADIUS = 2.0 DEFAULT_FILTER_TIME_CONSTANT = 10 @@ -39,7 +40,8 @@ ICON = 'mdi:chart-line-variant' FILTER_SCHEMA = vol.Schema({ - vol.Optional(CONF_FILTER_WINDOW_SIZE): vol.Coerce(int), + vol.Optional(CONF_FILTER_WINDOW_SIZE, + default=DEFAULT_WINDOW_SIZE): vol.Coerce(int), vol.Optional(CONF_FILTER_PRECISION): vol.Coerce(int), }) @@ -121,26 +123,27 @@ def filter_sensor_state_listener(entity, old_state, new_state): ATTR_UNIT_OF_MEASUREMENT) self._icon = new_state.attributes.get(ATTR_ICON, self._icon) - self._state = new_state.state - - if self._state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: return - for filt in self._filters: - try: + self._state = new_state.state + + try: + for filt in self._filters: filtered_state = filt.filter_state(self._state) - _LOGGER.debug("%s(%s, %s) -> %s", filt.name, + _LOGGER.debug("%s(%s=%s) -> %s", filt.name, self._entity, self._state, "skip" if filt.skip_processing else filtered_state) if filt.skip_processing: return - except ValueError: - _LOGGER.warning("Could not convert state: %s to number", - self._state) - finally: self._state = filtered_state + except ValueError: + _LOGGER.error("Could not convert state: %s to number", + self._state) + self._state = STATE_UNAVAILABLE + return self.async_schedule_update_ha_state() @@ -178,15 +181,6 @@ def device_state_attributes(self): state_attr = { ATTR_ENTITY_ID: self._entity } - for filt in self._filters: - for filt_stat_key, filt_stat_value in filt.stats.items(): - filt_stat = "{}_{}".format(filt.name, filt_stat_key) - _LOGGER.debug("stats(%s): %s: %s", self._entity, - filt_stat, filt_stat_value) - state_attr.update({ - filt_stat: filt_stat_value - }) - return state_attr @@ -204,7 +198,6 @@ def __init__(self, name, window_size=1, precision=None, entity=None): """Initialize common attributes.""" self.states = deque(maxlen=window_size) self.precision = precision - self._stats = {} self._name = name self._entity = entity self._skip_processing = False @@ -219,11 +212,6 @@ def skip_processing(self): """Return wether the current filter_state should be skipped.""" return self._skip_processing - @property - def stats(self): - """Return statistics of the filter.""" - return self._stats - def _filter_state(self, new_state): """Implement filter.""" raise NotImplementedError() @@ -254,21 +242,17 @@ def __init__(self, window_size, precision, entity, radius): def _filter_state(self, new_state): """Implement the outlier filter.""" - self._stats_internal['total_filtered'] += 1 new_state = float(new_state) - self._stats['erasures'] = "{0:.2f}%".format( - 100 * self._stats_internal['erasures'] - / self._stats_internal['total_filtered'] - ) - - if (len(self.states) > 1 and + if (self.states and abs(new_state - statistics.median(self.states)) > self._radius): self._stats_internal['erasures'] += 1 - _LOGGER.debug("Outlier in %s: %s", self._entity, new_state) + _LOGGER.debug("Outlier nr. %s in %s: %s", + self._stats_internal['erasures'], + self._entity, new_state) return self.states[-1] return new_state diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index 0acedac5fa0ca7..dd1112d65f8832 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -43,6 +43,7 @@ def test_chain(self): 'radius': 4.0 }, { 'filter': 'lowpass', + 'window_size': 4, 'time_constant': 10, 'precision': 2 }] From 11c3e525313787ab553adc3c4bab38bb809f9ae2 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Wed, 28 Feb 2018 23:01:23 +0000 Subject: [PATCH 20/22] only round floats --- homeassistant/components/sensor/filter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index a020e299a9a894..d296f7d74dd98e 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -220,7 +220,7 @@ def filter_state(self, new_state): """Implement a common interface for filters.""" filtered = self._filter_state(new_state) if self.precision is not None: - filtered = round(filtered, self.precision) + filtered = round(float(filtered), self.precision) self.states.append(filtered) return filtered From 5adf9120172f99e5db960f3c805046059d2517f2 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Thu, 1 Mar 2018 01:01:00 +0000 Subject: [PATCH 21/22] Registry decorator usage --- homeassistant/components/sensor/filter.py | 38 +++++++++-------------- 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index d296f7d74dd98e..37cc2cd6c730a8 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -7,6 +7,7 @@ import logging import statistics from collections import deque, Counter +from numbers import Number import voluptuous as vol @@ -16,6 +17,7 @@ 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 @@ -24,6 +26,7 @@ FILTER_NAME_LOWPASS = 'lowpass' FILTER_NAME_OUTLIER = 'outlier' FILTER_NAME_THROTTLE = 'throttle' +FILTERS = Registry() CONF_FILTERS = 'filters' CONF_FILTER_NAME = 'filter' @@ -33,6 +36,7 @@ CONF_FILTER_TIME_CONSTANT = 'time_constant' DEFAULT_WINDOW_SIZE = 1 +DEFAULT_PRECISION = 2 DEFAULT_FILTER_RADIUS = 2.0 DEFAULT_FILTER_TIME_CONSTANT = 10 @@ -42,7 +46,8 @@ FILTER_SCHEMA = vol.Schema({ vol.Optional(CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE): vol.Coerce(int), - vol.Optional(CONF_FILTER_PRECISION): vol.Coerce(int), + vol.Optional(CONF_FILTER_PRECISION, + default=DEFAULT_PRECISION): vol.Coerce(int), }) FILTER_OUTLIER_SCHEMA = FILTER_SCHEMA.extend({ @@ -76,28 +81,10 @@ async def async_setup_platform(hass, config, async_add_devices, """Set up the template sensors.""" name = config.get(CONF_NAME) entity_id = config.get(CONF_ENTITY_ID) - filters = [] - - for _filter in config[CONF_FILTERS]: - window_size = _filter.get(CONF_FILTER_WINDOW_SIZE) - precision = _filter.get(CONF_FILTER_PRECISION) - - if _filter[CONF_FILTER_NAME] == FILTER_NAME_OUTLIER: - radius = _filter.get(CONF_FILTER_RADIUS) - filters.append(OutlierFilter(window_size=window_size, - precision=precision, - entity=entity_id, - radius=radius)) - elif _filter[CONF_FILTER_NAME] == FILTER_NAME_LOWPASS: - time_constant = _filter.get(CONF_FILTER_TIME_CONSTANT) - filters.append(LowPassFilter(window_size=window_size, - precision=precision, - entity=entity_id, - time_constant=time_constant)) - elif _filter[CONF_FILTER_NAME] == FILTER_NAME_THROTTLE: - filters.append(ThrottleFilter(window_size=window_size, - precision=precision, - entity=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)]) @@ -219,12 +206,13 @@ def _filter_state(self, new_state): def filter_state(self, new_state): """Implement a common interface for filters.""" filtered = self._filter_state(new_state) - if self.precision is not None: + 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. @@ -257,6 +245,7 @@ def _filter_state(self, new_state): return new_state +@FILTERS.register(FILTER_NAME_LOWPASS) class LowPassFilter(Filter): """BASIC Low Pass Filter. @@ -283,6 +272,7 @@ def _filter_state(self, new_state): return filtered +@FILTERS.register(FILTER_NAME_THROTTLE) class ThrottleFilter(Filter): """Throttle Filter. From 423c683425a12515140ba3058628e9bb84e695c0 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Thu, 1 Mar 2018 14:06:30 +0000 Subject: [PATCH 22/22] Tries to address remaining comments --- homeassistant/components/sensor/filter.py | 25 ++++++++++++++--------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 37cc2cd6c730a8..cde50699b29c82 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -99,39 +99,44 @@ def __init__(self, name, entity_id, filters): self._unit_of_measurement = None self._state = None self._filters = filters - self._icon = ICON + 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.""" - self._unit_of_measurement = new_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT) - self._icon = new_state.attributes.get(ATTR_ICON, self._icon) - if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: return - self._state = new_state.state + temp_state = new_state.state try: for filt in self._filters: - filtered_state = filt.filter_state(self._state) + filtered_state = filt.filter_state(temp_state) _LOGGER.debug("%s(%s=%s) -> %s", filt.name, self._entity, - self._state, + temp_state, "skip" if filt.skip_processing else filtered_state) if filt.skip_processing: return - self._state = filtered_state + temp_state = filtered_state except ValueError: _LOGGER.error("Could not convert state: %s to number", self._state) - self._state = STATE_UNAVAILABLE 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(