Add history_graph component (#9472)

* Add support for multi-entity recent fetch of history. Add graph component

* Rename graph to history_graph. Support fast fetch without current state.

* Address comments
This commit is contained in:
Andrey 2017-09-23 20:01:48 +03:00 committed by Paulus Schoutsen
parent f1aef33dd6
commit 499382a9a9
4 changed files with 214 additions and 17 deletions

View File

@ -48,8 +48,8 @@ def last_recorder_run(hass):
return res
def get_significant_states(hass, start_time, end_time=None, entity_id=None,
filters=None):
def get_significant_states(hass, start_time, end_time=None, entity_ids=None,
filters=None, include_start_time_state=True):
"""
Return states changes during UTC period start_time - end_time.
@ -60,8 +60,6 @@ def get_significant_states(hass, start_time, end_time=None, entity_id=None,
timer_start = time.perf_counter()
from homeassistant.components.recorder.models import States
entity_ids = (entity_id.lower(), ) if entity_id is not None else None
with session_scope(hass=hass) as session:
query = session.query(States).filter(
(States.domain.in_(SIGNIFICANT_DOMAINS) |
@ -86,7 +84,9 @@ def get_significant_states(hass, start_time, end_time=None, entity_id=None,
_LOGGER.debug(
'get_significant_states took %fs', elapsed)
return states_to_json(hass, states, start_time, entity_id, filters)
return states_to_json(
hass, states, start_time, entity_ids, filters,
include_start_time_state)
def state_changes_during_period(hass, start_time, end_time=None,
@ -105,10 +105,12 @@ def state_changes_during_period(hass, start_time, end_time=None,
if entity_id is not None:
query = query.filter_by(entity_id=entity_id.lower())
entity_ids = [entity_id] if entity_id is not None else None
states = execute(
query.order_by(States.last_updated))
return states_to_json(hass, states, start_time, entity_id)
return states_to_json(hass, states, start_time, entity_ids)
def get_states(hass, utc_point_in_time, entity_ids=None, run=None,
@ -185,7 +187,13 @@ def get_states(hass, utc_point_in_time, entity_ids=None, run=None,
if not state.attributes.get(ATTR_HIDDEN, False)]
def states_to_json(hass, states, start_time, entity_id, filters=None):
def states_to_json(
hass,
states,
start_time,
entity_ids,
filters=None,
include_start_time_state=True):
"""Convert SQL results into JSON friendly data structure.
This takes our state list and turns it into a JSON friendly data
@ -197,14 +205,13 @@ def states_to_json(hass, states, start_time, entity_id, filters=None):
"""
result = defaultdict(list)
entity_ids = [entity_id] if entity_id is not None else None
# Get the states at the start time
timer_start = time.perf_counter()
for state in get_states(hass, start_time, entity_ids, filters=filters):
state.last_changed = start_time
state.last_updated = start_time
result[state.entity_id].append(state)
if include_start_time_state:
for state in get_states(hass, start_time, entity_ids, filters=filters):
state.last_changed = start_time
state.last_updated = start_time
result[state.entity_id].append(state)
if _LOGGER.isEnabledFor(logging.DEBUG):
elapsed = time.perf_counter() - timer_start
@ -250,7 +257,7 @@ class HistoryPeriodView(HomeAssistantView):
extra_urls = ['/api/history/period/{datetime}']
def __init__(self, filters):
"""Initilalize the history period view."""
"""Initialize the history period view."""
self.filters = filters
@asyncio.coroutine
@ -282,11 +289,14 @@ class HistoryPeriodView(HomeAssistantView):
return self.json_message('Invalid end_time', HTTP_BAD_REQUEST)
else:
end_time = start_time + one_day
entity_id = request.query.get('filter_entity_id')
entity_ids = request.query.get('filter_entity_id')
if entity_ids:
entity_ids = entity_ids.lower().split(',')
include_start_time_state = 'skip_initial_state' not in request.query
result = yield from request.app['hass'].async_add_job(
get_significant_states, request.app['hass'], start_time, end_time,
entity_id, self.filters)
entity_ids, self.filters, include_start_time_state)
result = result.values()
if _LOGGER.isEnabledFor(logging.DEBUG):
elapsed = time.perf_counter() - timer_start

View File

@ -0,0 +1,87 @@
"""
Support to graphs card in the UI.
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/history_graph/
"""
import asyncio
import logging
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant.const import CONF_ENTITIES, CONF_NAME, ATTR_ENTITY_ID
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent
DEPENDENCIES = ['history']
_LOGGER = logging.getLogger(__name__)
DOMAIN = 'history_graph'
CONF_HOURS_TO_SHOW = 'hours_to_show'
CONF_REFRESH = 'refresh'
ATTR_HOURS_TO_SHOW = CONF_HOURS_TO_SHOW
ATTR_REFRESH = CONF_REFRESH
GRAPH_SCHEMA = vol.Schema({
vol.Required(CONF_ENTITIES): cv.entity_ids,
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_HOURS_TO_SHOW, default=24): vol.Range(min=1),
vol.Optional(CONF_REFRESH, default=0): vol.Range(min=0),
})
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({cv.slug: GRAPH_SCHEMA})
}, extra=vol.ALLOW_EXTRA)
@asyncio.coroutine
def async_setup(hass, config):
"""Load graph configurations."""
component = EntityComponent(
_LOGGER, DOMAIN, hass)
graphs = []
for object_id, cfg in config[DOMAIN].items():
name = cfg.get(CONF_NAME, object_id)
graph = HistoryGraphEntity(name, cfg)
graphs.append(graph)
yield from component.async_add_entities(graphs)
return True
class HistoryGraphEntity(Entity):
"""Representation of a graph entity."""
def __init__(self, name, cfg):
"""Initialize the graph."""
self._name = name
self._hours = cfg[CONF_HOURS_TO_SHOW]
self._refresh = cfg[CONF_REFRESH]
self._entities = cfg[CONF_ENTITIES]
@property
def should_poll(self):
"""No polling needed."""
return False
@property
def name(self):
"""Return the name of the entity."""
return self._name
@property
def state_attributes(self):
"""Return the state attributes."""
attrs = {
ATTR_HOURS_TO_SHOW: self._hours,
ATTR_REFRESH: self._refresh,
ATTR_ENTITY_ID: self._entities,
}
return attrs

View File

@ -145,6 +145,48 @@ class TestComponentHistory(unittest.TestCase):
self.hass, zero, four, filters=history.Filters())
assert states == hist
def test_get_significant_states_with_initial(self):
"""Test that only significant states are returned.
We should get back every thermostat change that
includes an attribute change, but only the state updates for
media player (attribute changes are not significant and not returned).
"""
zero, four, states = self.record_states()
one = zero + timedelta(seconds=1)
one_and_half = zero + timedelta(seconds=1.5)
for entity_id in states:
if entity_id == 'media_player.test':
states[entity_id] = states[entity_id][1:]
for state in states[entity_id]:
if state.last_changed == one:
state.last_changed = one_and_half
hist = history.get_significant_states(
self.hass, one_and_half, four, filters=history.Filters(),
include_start_time_state=True)
assert states == hist
def test_get_significant_states_without_initial(self):
"""Test that only significant states are returned.
We should get back every thermostat change that
includes an attribute change, but only the state updates for
media player (attribute changes are not significant and not returned).
"""
zero, four, states = self.record_states()
one = zero + timedelta(seconds=1)
one_and_half = zero + timedelta(seconds=1.5)
for entity_id in states:
states[entity_id] = list(filter(
lambda s: s.last_changed != one, states[entity_id]))
del states['media_player.test2']
hist = history.get_significant_states(
self.hass, one_and_half, four, filters=history.Filters(),
include_start_time_state=False)
assert states == hist
def test_get_significant_states_entity_id(self):
"""Test that only significant states are returned for one entity."""
zero, four, states = self.record_states()
@ -154,7 +196,19 @@ class TestComponentHistory(unittest.TestCase):
del states['script.can_cancel_this_one']
hist = history.get_significant_states(
self.hass, zero, four, 'media_player.test',
self.hass, zero, four, ['media_player.test'],
filters=history.Filters())
assert states == hist
def test_get_significant_states_multiple_entity_ids(self):
"""Test that only significant states are returned for one entity."""
zero, four, states = self.record_states()
del states['media_player.test2']
del states['thermostat.test2']
del states['script.can_cancel_this_one']
hist = history.get_significant_states(
self.hass, zero, four, ['media_player.test', 'thermostat.test'],
filters=history.Filters())
assert states == hist

View File

@ -0,0 +1,46 @@
"""The tests the Graph component."""
import unittest
from homeassistant.setup import setup_component
from tests.common import init_recorder_component, get_test_home_assistant
class TestGraph(unittest.TestCase):
"""Test the Google component."""
def setUp(self): # pylint: disable=invalid-name
"""Setup things to be run when tests are started."""
self.hass = get_test_home_assistant()
def tearDown(self): # pylint: disable=invalid-name
"""Stop everything that was started."""
self.hass.stop()
def test_setup_component(self):
"""Test setup component."""
self.init_recorder()
config = {
'history': {
},
'history_graph': {
'name_1': {
'entities': 'test.test',
}
}
}
self.assertTrue(setup_component(self.hass, 'history_graph', config))
self.assertEqual(
dict(self.hass.states.get('history_graph.name_1').attributes),
{
'entity_id': ['test.test'],
'friendly_name': 'name_1',
'hours_to_show': 24,
'refresh': 0
})
def init_recorder(self):
"""Initialize the recorder."""
init_recorder_component(self.hass)
self.hass.start()