Skip to content
Closed
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
df77481
Initial commit
dgomes Feb 13, 2018
3bcfe01
Merge branch 'home-assistant/dev' into filter
dgomes Feb 16, 2018
670a732
Some comments
robmarkcole Feb 16, 2018
14ea169
remove requirement
dgomes Feb 16, 2018
0a8a218
Added Filter as a decorator
dgomes Feb 16, 2018
8af0b64
style fixing
dgomes Feb 16, 2018
37c9591
style fixing
dgomes Feb 16, 2018
a173cb9
style fixing
dgomes Feb 16, 2018
2c622d7
style fixing
dgomes Feb 16, 2018
c80e1e0
moved stuff to decorator
dgomes Feb 16, 2018
fe54c14
style fixing
dgomes Feb 16, 2018
34db8ac
Added arguments to filters
dgomes Feb 17, 2018
5f8ee92
doc
dgomes Feb 17, 2018
9c51a2a
doc
dgomes Feb 17, 2018
7b7acbc
lint
dgomes Feb 17, 2018
8484e33
lint
dgomes Feb 17, 2018
b4a73e3
documentation
dgomes Feb 17, 2018
f92456d
lint
dgomes Feb 17, 2018
8fb7241
documentation
dgomes Feb 17, 2018
9130e59
better documentation
dgomes Feb 17, 2018
c09bd6a
lint
dgomes Feb 17, 2018
f6caace
make all arguments optional
dgomes Feb 17, 2018
0d33bb2
lint
dgomes Feb 17, 2018
ab11a3e
cleanup
dgomes Feb 18, 2018
cd0545a
lint
dgomes Feb 18, 2018
1d30b7f
outliers are everything out of band
dgomes Feb 18, 2018
d719c82
lint
dgomes Feb 18, 2018
d23d0a1
test for None or STATE_UNKNOWN state
dgomes Feb 18, 2018
89f5d91
precision option
dgomes Feb 18, 2018
b0467d3
Refactor attending comments
dgomes Feb 20, 2018
2735ebd
lint
dgomes Feb 20, 2018
167f35f
cleanup
dgomes Feb 20, 2018
2678d11
addresses comments
dgomes Feb 20, 2018
02afe5e
Removing asyncio.coroutine syntax
dgomes Feb 20, 2018
523757b
Removing asyncio.coroutine syntax (lint)
dgomes Feb 20, 2018
44bb2b9
consistency with documentation
dgomes Feb 21, 2018
8529ebf
tweaks
dgomes Feb 21, 2018
d850ab0
lint
dgomes Feb 21, 2018
cdefc52
tests added
dgomes Feb 21, 2018
9471220
lint
dgomes Feb 21, 2018
a5f37d5
redundant test removed
dgomes Feb 21, 2018
bf647cf
0.64 is still Python3.4
dgomes Feb 22, 2018
0fde29c
0.64 is still Python3.4
dgomes Feb 22, 2018
ce1fcb4
moved helpers into component
dgomes Feb 22, 2018
0008547
lint
dgomes Feb 22, 2018
1ab4672
lint
dgomes Feb 22, 2018
c0fd44d
lint
dgomes Feb 22, 2018
37f3d53
lint
dgomes Feb 22, 2018
b74e054
flake8
dgomes Feb 22, 2018
2986e7d
moved to component
dgomes Feb 22, 2018
af38e66
renamed filter to data_filter
dgomes Feb 22, 2018
4dfe2fa
fix indentation
dgomes Feb 22, 2018
0e843c0
test update to new name
dgomes Feb 22, 2018
b097d5d
travis pylint
dgomes Feb 23, 2018
d132032
small doc fixes
dgomes Feb 23, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions homeassistant/components/sensor/filter.py
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),

Copy link
Copy Markdown
Member

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 like 1:23. Returns a timedelta object, retrieve seconds with value.total_seconds()

Copy link
Copy Markdown
Contributor Author

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 :(

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),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use vol.In(list(FILTERS))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No return values for platform setups.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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
136 changes: 136 additions & 0 deletions homeassistant/helpers/filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""Deprecation helpers for Home Assistant."""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stale doc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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(…)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will give it a go

FILTER_LOWPASS: _lowpass,
FILTER_OUTLIER: _outlier,
}