-
-
Notifications
You must be signed in to change notification settings - Fork 38.2k
Data Filter Sensor (was: Filter helper decorator) #12506
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 32 commits
df77481
3bcfe01
670a732
14ea169
0a8a218
8af0b64
37c9591
a173cb9
2c622d7
c80e1e0
fe54c14
34db8ac
5f8ee92
9c51a2a
7b7acbc
8484e33
b4a73e3
f92456d
8fb7241
9130e59
c09bd6a
f6caace
0d33bb2
ab11a3e
cd0545a
1d30b7f
d719c82
d23d0a1
89f5d91
b0467d3
2735ebd
167f35f
2678d11
02afe5e
523757b
44bb2b9
8529ebf
d850ab0
cdefc52
9471220
a5f37d5
bf647cf
0fde29c
ce1fcb4
0008547
1ab4672
c0fd44d
37f3d53
b74e054
2986e7d
af38e66
4dfe2fa
0e843c0
b097d5d
d132032
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,177 @@ | ||
| """ | ||
| Support for filtering for sensor values. | ||
|
|
||
| Example configuration: | ||
|
|
||
| sensor: | ||
| - platform: simulated | ||
| name: 'simulated relative humidity' | ||
| unit: '%' | ||
| amplitude: 0 # Turns off sine wave | ||
| mean: 50 | ||
| spread: 10 | ||
| seed: 999 | ||
|
|
||
| - platform: filter | ||
| entity_id: sensor.simulated_relative_humidity | ||
| name: lowpass | ||
| options: | ||
| time_constant: 10 | ||
|
|
||
| For more details about this platform, please refer to the documentation at | ||
| https://home-assistant.io/components/sensor.filter/ | ||
| """ | ||
| import asyncio | ||
| import logging | ||
|
|
||
| import voluptuous as vol | ||
|
|
||
| import homeassistant.helpers.config_validation as cv | ||
| from homeassistant.components.sensor import PLATFORM_SCHEMA | ||
| from homeassistant.const import ( | ||
| CONF_NAME, CONF_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN) | ||
| from homeassistant.core import callback | ||
| from homeassistant.helpers.entity import Entity | ||
| from homeassistant.helpers.filter import ( | ||
| Filter, FILTERS) | ||
| from homeassistant.helpers.event import async_track_state_change | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| ATTR_PRE_FILTER = 'pre_filter_state' | ||
|
|
||
| CONF_FILTER_OPTIONS = 'options' | ||
| CONF_FILTER_NAME = 'filter' | ||
|
|
||
| DEFAULT_NAME_TEMPLATE = "{} filter {}" | ||
| ICON = 'mdi: chart-line-variant' | ||
|
|
||
| # ALL filter arguments must be OPTIONAL | ||
| FILTER_SCHEMA = vol.Schema({ | ||
| vol.Optional('time_constant'): vol.Coerce(int), | ||
| vol.Optional('constant'): vol.Coerce(float) | ||
| }) | ||
|
|
||
| PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ | ||
| vol.Required(CONF_ENTITY_ID): cv.entity_id, | ||
| vol.Required(CONF_FILTER_NAME): | ||
| vol.Any(*FILTERS), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. find my version quite pythonic :) but changing... |
||
| vol.Optional(CONF_FILTER_OPTIONS): FILTER_SCHEMA, | ||
| vol.Optional(CONF_NAME, default=None): cv.string, | ||
| }) | ||
|
|
||
|
|
||
| @asyncio.coroutine | ||
| def async_setup_platform(hass, config, async_add_devices, discovery_info=None): | ||
| """Set up the Filter sensor.""" | ||
| entity_id = config.get(CONF_ENTITY_ID) | ||
| filter_name = config.get(CONF_FILTER_NAME) | ||
| name = config.get(CONF_NAME) | ||
| if name is None: | ||
| name = DEFAULT_NAME_TEMPLATE.format(entity_id, filter_name) | ||
|
|
||
| async_add_devices([ | ||
| FilterSensor(hass, name, entity_id, filter_name, | ||
| config.get(CONF_FILTER_OPTIONS, dict())) | ||
| ], True) | ||
| return True | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No return values for platform setups.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
|
|
||
|
|
||
| class FilterSensor(Entity): | ||
| """Representation of a Filter sensor.""" | ||
|
|
||
| def __init__(self, hass, name, entity_id, filter_name, filter_args): | ||
| """Initialize the Filter sensor.""" | ||
| self._name = name | ||
| self._filter_name = filter_name | ||
| self._unit_of_measurement = None | ||
| self._pre_filter_state = self._state = None | ||
|
|
||
| self._filterdata = self.filterdata_factory(filter_name, | ||
| **filter_args)(self._name) | ||
|
|
||
| @callback | ||
| # pylint: disable=invalid-name | ||
| def async_stats_sensor_state_listener(entity, old_state, new_state): | ||
| """Handle the sensor state changes.""" | ||
| self._unit_of_measurement = new_state.attributes.get( | ||
| ATTR_UNIT_OF_MEASUREMENT) | ||
|
|
||
| if new_state.state is None or new_state.state is STATE_UNKNOWN: | ||
| return | ||
|
|
||
| try: | ||
| self._pre_filter_state = new_state.state | ||
| self._filterdata.update(self._pre_filter_state) | ||
| except ValueError: | ||
| _LOGGER.warning("Could not convert <%s> into a number", | ||
| self._pre_filter_state) | ||
| return | ||
|
|
||
| hass.async_add_job(self.async_update_ha_state, True) | ||
|
|
||
| async_track_state_change( | ||
| hass, entity_id, async_stats_sensor_state_listener) | ||
|
|
||
| @property | ||
| def available(self): | ||
| """Return True if there is data to filter.""" | ||
| return self._pre_filter_state is not None | ||
|
|
||
| @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._filterdata.data | ||
|
|
||
| @property | ||
| def unit_of_measurement(self): | ||
| """Return the unit the value is expressed in.""" | ||
| return self._unit_of_measurement | ||
|
|
||
| @property | ||
| def should_poll(self): | ||
| """No polling needed.""" | ||
| return False | ||
|
|
||
| @property | ||
| def icon(self): | ||
| """Return the icon to use in the frontend.""" | ||
| return ICON | ||
|
|
||
| @property | ||
| def device_state_attributes(self): | ||
| """Return the state attributes of the sensor.""" | ||
| state_attr = { | ||
| ATTR_PRE_FILTER: self._pre_filter_state, | ||
| ATTR_UNIT_OF_MEASUREMENT: self._unit_of_measurement | ||
| } | ||
| state_attr.update(self._filterdata.stats) | ||
|
|
||
| return state_attr | ||
|
|
||
| def filterdata_factory(self, filter_function, **kwargs): | ||
| """Factory to create filters with user provided arguments.""" | ||
| class FilterData(object): | ||
| def __init__(self, sensor_name): | ||
| self._data = None | ||
| self.filter_stats = {} | ||
| self.entity_id = sensor_name | ||
|
|
||
| @property | ||
| @Filter(filter_function, **kwargs) | ||
| def data(self): | ||
| return self._data | ||
|
|
||
| @property | ||
| def stats(self): | ||
| return self.filter_stats | ||
|
|
||
| def update(self, new_data): | ||
| self._data = float(new_data) | ||
|
|
||
| return FilterData | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| """Deprecation helpers for Home Assistant.""" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. stale doc.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ups! |
||
| import logging | ||
| import inspect | ||
| import statistics | ||
| from collections import deque | ||
|
|
||
| DEFAULT_WINDOW_SIZE = 5 | ||
| FILTER_LOWPASS = 'lowpass' | ||
| FILTER_OUTLIER = 'outlier' | ||
|
|
||
|
|
||
| class Filter(object): | ||
| """Filter decorator.""" | ||
|
|
||
| logger = None | ||
| sensor_name = None | ||
|
|
||
| def __init__(self, filter_algorithm, window_size=DEFAULT_WINDOW_SIZE, | ||
| **kwargs): | ||
| """Decorator constructor, selects algorithm and configures window. | ||
|
|
||
| Args: | ||
| filter_algorithm (int): must be one of the defined filters | ||
| window_size (int): size of the sliding window that holds previous | ||
| values | ||
| kwargs (dict): arguments to be passed to the specific filter | ||
|
|
||
| """ | ||
| module_name = inspect.getmodule(inspect.stack()[1][0]).__name__ | ||
| Filter.logger = logging.getLogger(module_name) | ||
| Filter.logger.debug("Filter %s(%s) on %s", filter_algorithm, kwargs, | ||
| module_name) | ||
| self.filter_args = kwargs | ||
| self.filter_stats = {'filter': filter_algorithm} | ||
| self.states = deque(maxlen=window_size) | ||
|
|
||
| if filter_algorithm in FILTERS: | ||
| self.filter = FILTERS[filter_algorithm] | ||
| else: | ||
| self.logger.error("Unknown filter <%s>", filter_algorithm) | ||
| return | ||
|
|
||
| def __call__(self, func): | ||
| """Decorate function as filter.""" | ||
| def func_wrapper(sensor_object): | ||
| """Wrap for the original state() function.""" | ||
| Filter.sensor_name = sensor_object.entity_id | ||
| new_state = func(sensor_object) | ||
| try: | ||
| filtered_state = self.filter(new_state=float(new_state), | ||
| stats=self.filter_stats, | ||
| states=self.states, | ||
| **self.filter_args) | ||
| except TypeError: | ||
| return None | ||
|
|
||
| self.states.append(filtered_state) | ||
|
|
||
| """ filter_stats makes available few statistics to the sensor """ | ||
| sensor_object.filter_stats = self.filter_stats | ||
|
|
||
| Filter.logger.debug("%s(%s) -> %s", self.filter_stats['filter'], | ||
| new_state, filtered_state) | ||
| return filtered_state | ||
|
|
||
| return func_wrapper | ||
|
|
||
|
|
||
| def _outlier(new_state, stats, states, **kwargs): | ||
| """BASIC outlier filter. | ||
|
|
||
| Determines if new state in a band around the median | ||
|
|
||
| Args: | ||
| new_state (float): new value to the series | ||
| stats (dict): used to feedback stats on the filter | ||
| states (deque): previous data series | ||
| constant (int): median multiplier/band range | ||
|
|
||
| Returns: | ||
| the original new_state case not an outlier | ||
| the median of the window case it's an outlier | ||
|
|
||
| """ | ||
| constant = kwargs.pop('constant', 0.10) | ||
| erasures = stats.get('erasures', 0) | ||
|
|
||
| if (len(states) > 1 and | ||
| abs(new_state - statistics.median(states)) > | ||
| constant*statistics.median(states)): | ||
|
|
||
| stats['erasures'] = erasures+1 | ||
| Filter.logger.warning("Outlier in %s: %s", | ||
| Filter.sensor_name, float(new_state)) | ||
| return statistics.median(states) | ||
| return new_state | ||
|
|
||
|
|
||
| def _lowpass(new_state, stats, states, **kwargs): | ||
| """BASIC Low Pass Filter. | ||
|
|
||
| Args: | ||
| new_state (float): new value to the series | ||
| stats (dict): used to feedback stats on the filter | ||
| states (deque): previous data series | ||
| time_constant (int): time constant. | ||
|
|
||
| Returns: | ||
| a new state value that has been smoothed by filter | ||
|
|
||
| """ | ||
| time_constant = kwargs.pop('time_constant', 4) | ||
| precision = kwargs.pop('precision', None) | ||
|
|
||
| if len(kwargs) != 0: | ||
| Filter.logger.error("unrecognized params passed in: %s", kwargs) | ||
|
|
||
| try: | ||
| B = 1.0 / time_constant | ||
| A = 1.0 - B | ||
| filtered = A * states[-1] + B * new_state | ||
| except IndexError: | ||
| # if we don't have enough states to run the filter | ||
| # just accept the new value | ||
| filtered = new_state | ||
|
|
||
| if precision is None: | ||
| return filtered | ||
| else: | ||
| return round(filtered, precision) | ||
|
|
||
|
|
||
| FILTERS = { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are missing an amazing opportunity to use our Registry from homeassistant.util.decorator import Registry
FILTERS = Registry()
@FILTERS.register(FILTER_LOWPASS)
def _lowpass(…)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will give it a go |
||
| FILTER_LOWPASS: _lowpass, | ||
| FILTER_OUTLIER: _outlier, | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Validate periods of time with
cv.time_period. Will still allow people to put in seconds but will also allow syntax like1:23. Returns a timedelta object, retrieve seconds withvalue.total_seconds()There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have to disagree with this suggestion.
The variable has time in its name, but no one uses time syntax to described it. Filters interface has been built to be as mathematical pure as possible, adding this option would make this design decision less soo :(