From 499382a9a9eab53a25ec3f67587fb64723fcc931 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 23 Sep 2017 20:01:48 +0300 Subject: [PATCH] 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 --- homeassistant/components/history.py | 42 ++++++----- homeassistant/components/history_graph.py | 87 +++++++++++++++++++++++ tests/components/test_history.py | 56 ++++++++++++++- tests/components/test_history_graph.py | 46 ++++++++++++ 4 files changed, 214 insertions(+), 17 deletions(-) create mode 100644 homeassistant/components/history_graph.py create mode 100644 tests/components/test_history_graph.py diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 5a3002c05f2..9863e823e06 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -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 diff --git a/homeassistant/components/history_graph.py b/homeassistant/components/history_graph.py new file mode 100644 index 00000000000..e6977d60c30 --- /dev/null +++ b/homeassistant/components/history_graph.py @@ -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 diff --git a/tests/components/test_history.py b/tests/components/test_history.py index d2ea03b1873..8484e2c536f 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -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 diff --git a/tests/components/test_history_graph.py b/tests/components/test_history_graph.py new file mode 100644 index 00000000000..554f7f29dd7 --- /dev/null +++ b/tests/components/test_history_graph.py @@ -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()