From 2bed2eeecb93c7c813885cc2149a9b5cc1748782 Mon Sep 17 00:00:00 2001 From: andrey-git Date: Sun, 17 Sep 2017 22:23:02 +0300 Subject: [PATCH 1/3] Add support for multi-entity recent fetch of history. Add graph component --- homeassistant/components/graph.py | 87 +++++++++++++++++++++++++++++ homeassistant/components/history.py | 14 ++--- tests/components/test_graph.py | 45 +++++++++++++++ tests/components/test_history.py | 12 ++++ 4 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/graph.py create mode 100644 tests/components/test_graph.py diff --git a/homeassistant/components/graph.py b/homeassistant/components/graph.py new file mode 100644 index 00000000000000..c0be9be84403d7 --- /dev/null +++ b/homeassistant/components/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/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 = ['frontend', 'history'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = '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 = GraphEntity(name, cfg) + graphs.append(graph) + + yield from component.async_add_entities(graphs) + + return True + + +class GraphEntity(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/homeassistant/components/history.py b/homeassistant/components/history.py index 5a3002c05f2a37..68bf1bd0c389c7 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -59,8 +59,8 @@ 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 + entity_ids = entity_id.lower().split( + ',') if entity_id is not None else None with session_scope(hass=hass) as session: query = session.query(States).filter( @@ -86,7 +86,7 @@ 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) 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,7 @@ 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): """Convert SQL results into JSON friendly data structure. This takes our state list and turns it into a JSON friendly data @@ -197,8 +199,6 @@ 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): diff --git a/tests/components/test_graph.py b/tests/components/test_graph.py new file mode 100644 index 00000000000000..7d7b3d2c609bba --- /dev/null +++ b/tests/components/test_graph.py @@ -0,0 +1,45 @@ +"""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': { + }, + 'graph': { + 'name_1': { + 'entities': 'test.test', + } + } + } + + self.assertTrue(setup_component(self.hass, 'graph', config)) + self.assertEqual(dict(self.hass.states.get('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() diff --git a/tests/components/test_history.py b/tests/components/test_history.py index d2ea03b1873ec0..18d622928a5887 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -158,6 +158,18 @@ def test_get_significant_states_entity_id(self): 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 + def test_get_significant_states_exclude_domain(self): """Test if significant states are returned when excluding domains. From 66d39742098dc3afe6ded9d82c777c54a79d9fda Mon Sep 17 00:00:00 2001 From: andrey-git Date: Wed, 20 Sep 2017 15:18:05 +0300 Subject: [PATCH 2/3] Rename graph to history_graph. Support fast fetch without current state. --- homeassistant/components/history.py | 27 ++++++++---- .../components/{graph.py => history_graph.py} | 8 ++-- tests/components/test_history.py | 42 +++++++++++++++++++ .../{test_graph.py => test_history_graph.py} | 19 +++++---- 4 files changed, 74 insertions(+), 22 deletions(-) rename homeassistant/components/{graph.py => history_graph.py} (93%) rename tests/components/{test_graph.py => test_history_graph.py} (68%) diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 68bf1bd0c389c7..c55a99e3f71331 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -49,7 +49,7 @@ def last_recorder_run(hass): def get_significant_states(hass, start_time, end_time=None, entity_id=None, - filters=None): + filters=None, get_initial_state=True): """ Return states changes during UTC period start_time - end_time. @@ -86,7 +86,8 @@ 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_ids, filters) + return states_to_json( + hass, states, start_time, entity_ids, filters, get_initial_state) def state_changes_during_period(hass, start_time, end_time=None, @@ -187,7 +188,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_ids, filters=None): +def states_to_json( + hass, + states, + start_time, + entity_ids, + filters=None, + get_initial_state=True): """Convert SQL results into JSON friendly data structure. This takes our state list and turns it into a JSON friendly data @@ -201,10 +208,11 @@ def states_to_json(hass, states, start_time, entity_ids, filters=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 get_initial_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 +258,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 @@ -283,10 +291,11 @@ def get(self, request, datetime=None): else: end_time = start_time + one_day entity_id = request.query.get('filter_entity_id') + get_initial_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_id, self.filters, get_initial_state) result = result.values() if _LOGGER.isEnabledFor(logging.DEBUG): elapsed = time.perf_counter() - timer_start diff --git a/homeassistant/components/graph.py b/homeassistant/components/history_graph.py similarity index 93% rename from homeassistant/components/graph.py rename to homeassistant/components/history_graph.py index c0be9be84403d7..2656afeec12776 100644 --- a/homeassistant/components/graph.py +++ b/homeassistant/components/history_graph.py @@ -2,7 +2,7 @@ Support to graphs card in the UI. For more details about this component, please refer to the documentation at -https://home-assistant.io/components/graph/ +https://home-assistant.io/components/history_graph/ """ import asyncio import logging @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) -DOMAIN = 'graph' +DOMAIN = 'history_graph' CONF_HOURS_TO_SHOW = 'hours_to_show' CONF_REFRESH = 'refresh' @@ -48,7 +48,7 @@ def async_setup(hass, config): for object_id, cfg in config[DOMAIN].items(): name = cfg.get(CONF_NAME, object_id) - graph = GraphEntity(name, cfg) + graph = HistoryGraphEntity(name, cfg) graphs.append(graph) yield from component.async_add_entities(graphs) @@ -56,7 +56,7 @@ def async_setup(hass, config): return True -class GraphEntity(Entity): +class HistoryGraphEntity(Entity): """Representation of a graph entity.""" def __init__(self, name, cfg): diff --git a/tests/components/test_history.py b/tests/components/test_history.py index 18d622928a5887..2cc7130fc6d22a 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -145,6 +145,48 @@ def test_get_significant_states(self): 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(), + get_initial_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(), + get_initial_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() diff --git a/tests/components/test_graph.py b/tests/components/test_history_graph.py similarity index 68% rename from tests/components/test_graph.py rename to tests/components/test_history_graph.py index 7d7b3d2c609bba..554f7f29dd76f9 100644 --- a/tests/components/test_graph.py +++ b/tests/components/test_history_graph.py @@ -23,21 +23,22 @@ def test_setup_component(self): config = { 'history': { }, - 'graph': { + 'history_graph': { 'name_1': { 'entities': 'test.test', } } } - self.assertTrue(setup_component(self.hass, 'graph', config)) - self.assertEqual(dict(self.hass.states.get('graph.name_1').attributes), - { - 'entity_id': ['test.test'], - 'friendly_name': 'name_1', - 'hours_to_show': 24, - 'refresh': 0 - }) + 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.""" From 0e57e28dde114296622869473d89b30b5476b4c8 Mon Sep 17 00:00:00 2001 From: andrey-git Date: Sat, 23 Sep 2017 09:13:17 +0300 Subject: [PATCH 3/3] Address comments --- homeassistant/components/history.py | 21 +++++++++++---------- homeassistant/components/history_graph.py | 2 +- tests/components/test_history.py | 8 ++++---- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index c55a99e3f71331..9863e823e067fb 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, get_initial_state=True): +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. @@ -59,8 +59,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().split( - ',') if entity_id is not None else None with session_scope(hass=hass) as session: query = session.query(States).filter( @@ -87,7 +85,8 @@ def get_significant_states(hass, start_time, end_time=None, entity_id=None, 'get_significant_states took %fs', elapsed) return states_to_json( - hass, states, start_time, entity_ids, filters, get_initial_state) + hass, states, start_time, entity_ids, filters, + include_start_time_state) def state_changes_during_period(hass, start_time, end_time=None, @@ -194,7 +193,7 @@ def states_to_json( start_time, entity_ids, filters=None, - get_initial_state=True): + 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 @@ -208,7 +207,7 @@ def states_to_json( # Get the states at the start time timer_start = time.perf_counter() - if get_initial_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 @@ -290,12 +289,14 @@ def get(self, request, datetime=None): 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') - get_initial_state = 'skip_initial_state' not in request.query + 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, get_initial_state) + 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 index 2656afeec12776..e6977d60c30a34 100644 --- a/homeassistant/components/history_graph.py +++ b/homeassistant/components/history_graph.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -DEPENDENCIES = ['frontend', 'history'] +DEPENDENCIES = ['history'] _LOGGER = logging.getLogger(__name__) diff --git a/tests/components/test_history.py b/tests/components/test_history.py index 2cc7130fc6d22a..8484e2c536ff3b 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -164,7 +164,7 @@ def test_get_significant_states_with_initial(self): hist = history.get_significant_states( self.hass, one_and_half, four, filters=history.Filters(), - get_initial_state=True) + include_start_time_state=True) assert states == hist def test_get_significant_states_without_initial(self): @@ -184,7 +184,7 @@ def test_get_significant_states_without_initial(self): hist = history.get_significant_states( self.hass, one_and_half, four, filters=history.Filters(), - get_initial_state=False) + include_start_time_state=False) assert states == hist def test_get_significant_states_entity_id(self): @@ -196,7 +196,7 @@ def test_get_significant_states_entity_id(self): 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 @@ -208,7 +208,7 @@ def test_get_significant_states_multiple_entity_ids(self): del states['script.can_cancel_this_one'] hist = history.get_significant_states( - self.hass, zero, four, 'media_player.test,thermostat.test', + self.hass, zero, four, ['media_player.test', 'thermostat.test'], filters=history.Filters()) assert states == hist