Filter Sensor#12650
Conversation
|
Couple of comments:
Cheers |
| }) | ||
|
|
||
| PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ | ||
| vol.Required(ATTR_ENTITY_ID): cv.entity_id, |
There was a problem hiding this comment.
I think this can be wrong interpreted from user. Maybe 'entity', 'state_from' is better?
There was a problem hiding this comment.
I would be for entity: since the plural form of that (entities:) is already used for group: and the upcoming group platforms.
There was a problem hiding this comment.
I used entity_id (singular) since that is what is used in template_sensor, min_max, history_stats (and I guess couple of other...)
So we either change all the others, or this is the common interpretation by users already...
|
@robmarkcole can also move outliers into debug() erasures -> numbers of outliers removed |
|
@dgomes numbers of outliers removed is just a number so not as helpful as a % IMO? |
MartinHjelmare
left a comment
There was a problem hiding this comment.
Looks good overall.
I haven't looked at the tests.
| 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, |
There was a problem hiding this comment.
ATTR_* constants should not be used in config schemas, but in service schemas. All constants in config schemas that represent keys should start with CONF_.
| 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, |
|
|
||
|
|
||
| @asyncio.coroutine | ||
| # pylint: disable=unused-argument |
There was a problem hiding this comment.
This is already globally disabled.
| entity_id = config.get(CONF_ENTITY_ID) | ||
| filters = [] | ||
|
|
||
| for _filter in config.get(CONF_FILTERS): |
There was a problem hiding this comment.
Don't use dict.get on required config keys. Just do dict[key].
| window_size = _filter.get(CONF_FILTER_WINDOW_SIZE) | ||
| precision = _filter.get(CONF_FILTER_PRECISION) | ||
|
|
||
| if _filter['filter'] == FILTER_NAME_OUTLIER: |
| self._state = filtered_state | ||
| filt.states.append(filtered_state) | ||
| except ValueError: | ||
| _LOGGER.warning("Could not convert state to number") |
There was a problem hiding this comment.
Please log what state failed the conversion.
| """ | ||
|
|
||
| def __init__(self, name, window_size=1, precision=None): | ||
| """Initialize common properties.""" |
There was a problem hiding this comment.
It's initializing attributes.
| """Initialize common properties.""" | ||
| self.states = deque(maxlen=window_size) | ||
| self.precision = precision | ||
| self._stats = dict() |
| class OutlierFilter(Filter): | ||
| """BASIC outlier filter. | ||
|
|
||
| Determines if new state is in a band around the median |
| } | ||
| for filt in self._filters: | ||
| state_attr.update({ | ||
| slugify("{} stats".format(filt.name)): filt.stats |
There was a problem hiding this comment.
How will the nested dict that we create for state attributes look in the frontend?
There was a problem hiding this comment.
this information is for fine tuning the filter options only. Should not be presented in the frontend.
There was a problem hiding this comment.
State attributes are shown in a table under the history graph in the more info card of sensors.
There was a problem hiding this comment.
Only alternative I see is to slugify the nested dict():
outlier_erasures: 123
lowpass_median: 50
comments ?
There was a problem hiding this comment.
Probably easiest to flatten the dicts into one dict, by concatenating + slugify the key names for the inner dict with the holding key name. I think that's what you mean too?
|
@robmarkcole you are right, changed! :) |
| with assert_setup_component(0): | ||
| assert setup_component(self.hass, 'sensor', config) | ||
|
|
||
| self.hass.start() |
There was a problem hiding this comment.
I don't think you need to start home assistant in any of the tests.
| assert setup_component(self.hass, 'sensor', config) | ||
|
|
||
| self.hass.start() | ||
| self.hass.block_till_done() |
There was a problem hiding this comment.
Since nothing is tested after this, this is not needed.
| DEFAULT_FILTER_TIME_CONSTANT = 10 | ||
|
|
||
| NAME_TEMPLATE = "{} filter" | ||
| ICON = 'mdi: chart-line-variant' |
There was a problem hiding this comment.
I don't think the space belongs here (between mdi: and chart-[...]) :)
Also: Wouldn't it be better if we copied the icon from the base entity? I mean for me, the use case for this platform would be to improve some shitty raw sensor values that deviate way too much. For example, for the following sensor I'd like to have some smoothing. It would therefore be nice not to have to manually set the icon for every single filter platform.
There was a problem hiding this comment.
good point, will get the icon from the "parent"
|
|
||
| sensors.append(SensorFilter(name, entity_id, filters)) | ||
|
|
||
| async_add_devices(sensors) |
There was a problem hiding this comment.
Just a style suggestion: Why not just async_add_devices([SensorFilter(name, entity_id, filters)])?
| with assert_setup_component(1): | ||
| assert setup_component(self.hass, 'sensor', config) | ||
|
|
||
| self.hass.start() |
There was a problem hiding this comment.
Same as Martin's comment: you don't need to call hass.start()
| with assert_setup_component(1): | ||
| assert setup_component(self.hass, 'sensor', config) | ||
|
|
||
| self.hass.start() |
|
Sorry for bothering you this much :) Just another quick suggestion/use-case: I think another brilliant use of this sensor platform would be to have a filter that only emits a new state on every For example I have a simple temperature sensor outside that tracks the outside temperature, but it emits waaay too many values and makes the showing of the graph in the front-end really slow. If I understand correctly the current design for filters expects a new value to be spit out by the filter for every new state it receives (short off raising Maybe a bit out of scope for this PR, but it would be good to have a way for a filter to signal that it doesn't want to emit a new state. What do you think? |
|
Sounds simple enough, a SampleFilter, but let me get this PR in first 😁 BTW: several other filters are possible and easy to add, especially if we introduce scipy as a dependency :) |
| else: | ||
| self._skip = True | ||
|
|
||
| return new_state |
| elif _filter[CONF_FILTER_NAME] == FILTER_NAME_THROTTLE: | ||
| filters.append(ThrottleFilter(window_size=window_size, | ||
| precision=precision, | ||
| entity=entity_id)) |
There was a problem hiding this comment.
continuation line under-indented for visual indent
| time_constant=time_constant)) | ||
| elif _filter[CONF_FILTER_NAME] == FILTER_NAME_THROTTLE: | ||
| filters.append(ThrottleFilter(window_size=window_size, | ||
| precision=precision, |
There was a problem hiding this comment.
continuation line under-indented for visual indent
|
Added extra Throttle Filter (very simple, but required some foundation work) @OttoWinter figured I could use the filter in my setup as well, so here goes an extra filter ;) - filter: throttle
window_size: 10It throttles to 1 sample per window, so you only need to define the window_size. |
|
I reverted... I just want the basic filter code through :) |
| filt_stat = "{}_{}".format(filt.name, filt_stat_key) | ||
| _LOGGER.debug("stats(%s): %s: %s", self._entity, | ||
| filt_stat, filt_stat_value) | ||
| state_attr.update({ |
There was a problem hiding this comment.
This is so expensive and inefficient. Create a dict with 1 value to then pass to the update method after which it is discarded? 🤔 You can do state_atr[filt_stat] = filt_stat_value but really, just go for the define it at once declaration I suggested earlier or explain to me why it is so important to log every attribute individually?
There was a problem hiding this comment.
@robmarkcole and @OttoWinter in the previous PR made a case for having stats of the filters.
Personally I think logging is enough to tune the filters, logging can even be done in the filter itself saving the dict.
If I don't get feedback from you guys, I'm taking the stats off from this PR.
There was a problem hiding this comment.
I say leave the stats of for now, since I coudn't even say what stats I'd request. Once I've been using the component a while then will be in a position to do some analysis and make a suggestion, cheers
There was a problem hiding this comment.
I don't think we should do stat reporting through attributes (if at all). I personally hate it when entities put completely unnecessary stuff in their attributes that just clutter the front end (and state machine)
There was a problem hiding this comment.
Then it's settled! :) uff ... one less feature to creep this PR
There was a problem hiding this comment.
Databases all over the world are cheering 😉
| @callback | ||
| def filter_sensor_state_listener(entity, old_state, new_state): | ||
| """Handle device state changes.""" | ||
| self._unit_of_measurement = new_state.attributes.get( |
There was a problem hiding this comment.
Should this one fall back to the old unit if there is none in the current state? (just like icon)
There was a problem hiding this comment.
filters have no units... they are pure math :)
while pure math might have an icon, makes no sense to have a default unit.
There was a problem hiding this comment.
With old unit I meant the unit of measurement of the previous state that was processed. For history it's important that all states of an entity have the same unit.
What happens now is that a state will have unit set to None if the source state becomes unavailable/unknown. We should probably move setting this instance variable when we know we're going to adopt a filtered version of the state.
| if filt.skip_processing: | ||
| return | ||
| self._state = filtered_state | ||
| except ValueError: |
There was a problem hiding this comment.
Where is the conversion happening? Also, wouldn't it make more sense to try convert it once, before running the filters?
There was a problem hiding this comment.
conversion happens inside filters that process numbers, moving the try catch there would replicate the same code several times...
| _LOGGER.error("Could not convert state: %s to number", | ||
| self._state) | ||
| self._state = STATE_UNAVAILABLE | ||
| return |
There was a problem hiding this comment.
Shouldn't we still update the state when this happens? (and thus not return)
There was a problem hiding this comment.
" just make sure that nothing is updated."
There was a problem hiding this comment.
If you're not going to change anything, you don't have to assign self._state either.
| if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: | ||
| return | ||
|
|
||
| self._state = new_state.state |
There was a problem hiding this comment.
I find this a very weird assignment as it is never used. I would prefer a local variable that is assigned to self._state at the end. That way if something unexpected blows up, you are not left with a temp variable as state.
There was a problem hiding this comment.
its not temp, its the state of the previous filter
There was a problem hiding this comment.
new_state.state is the state that triggered the state change. It has not been filtered yet. Since we have at least 1 filter, this assignment will never last when we update Home Assistant.
There was a problem hiding this comment.
I would expect only to write values to self._state that you are intending to publish to Home Assistant. Class instance variables should not be used for temporary variables inside the scope.
| window_size = _filter.get(CONF_FILTER_WINDOW_SIZE) | ||
| precision = _filter.get(CONF_FILTER_PRECISION) | ||
|
|
||
| if _filter[CONF_FILTER_NAME] == FILTER_NAME_OUTLIER: |
There was a problem hiding this comment.
You can do this easier. If you use the Registry decorator, you would have all classes by type in the registry. Then the config is already valid from the schema and maps to the keywords.
FILTERS = Registry()
@FILTERS.register(FILTER_NAME_LOWPASS)
class LowPassFilter(Filter):
…
for filter_args in filters:
filter_args = filter_args.copy()
filter_args['entity'] = entity_id
filter_class = FILTERS[filter_args.pop(CONF_FILTER_NAME)]
filters.append(filter_class(**filter_args))
balloob
left a comment
There was a problem hiding this comment.
🎉 🎉 🎉 🎉 🎉 🎉 🎉 🎉 🎉 🎉
Thank you for working through all my comments 🥇
|
🎊 Was starting to loose hope! |
|
This is awesome. Really need filtering.. Will be great for Weather related sensors with Wind , temp etc.. Is it possible to add ability to exclude certain values (like ZERO ) from time series during calculations. |
|
@B1tMaster this PR is already closed :) I think your use cases are already covered by the basic filters, but feel free to add new ones ;) |



Description:
Introduces the Filter Sensor platform (This PR comes in the aftermath of #12506)
This is a full rewrite using the Template Sensor pattern, and the configuration suggestion of @OttoWinter (thus supporting chaining).
Related issue (if applicable): fixes #12506
Pull request in home-assistant.github.io with documentation (if applicable): home-assistant/home-assistant.io#4725
Example entry for
configuration.yaml(if applicable):Checklist:
If user exposed functionality or configuration variables are added/changed:
If the code does not interact with devices:
toxrun successfully. Your PR cannot be merged unless tests pass