mirror of
https://github.com/home-assistant/core.git
synced 2025-07-24 21:57:51 +00:00
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:
parent
f1aef33dd6
commit
499382a9a9
@ -48,8 +48,8 @@ def last_recorder_run(hass):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
def get_significant_states(hass, start_time, end_time=None, entity_id=None,
|
def get_significant_states(hass, start_time, end_time=None, entity_ids=None,
|
||||||
filters=None):
|
filters=None, include_start_time_state=True):
|
||||||
"""
|
"""
|
||||||
Return states changes during UTC period start_time - end_time.
|
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()
|
timer_start = time.perf_counter()
|
||||||
from homeassistant.components.recorder.models import States
|
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:
|
with session_scope(hass=hass) as session:
|
||||||
query = session.query(States).filter(
|
query = session.query(States).filter(
|
||||||
(States.domain.in_(SIGNIFICANT_DOMAINS) |
|
(States.domain.in_(SIGNIFICANT_DOMAINS) |
|
||||||
@ -86,7 +84,9 @@ def get_significant_states(hass, start_time, end_time=None, entity_id=None,
|
|||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
'get_significant_states took %fs', elapsed)
|
'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,
|
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:
|
if entity_id is not None:
|
||||||
query = query.filter_by(entity_id=entity_id.lower())
|
query = query.filter_by(entity_id=entity_id.lower())
|
||||||
|
|
||||||
|
entity_ids = [entity_id] if entity_id is not None else None
|
||||||
|
|
||||||
states = execute(
|
states = execute(
|
||||||
query.order_by(States.last_updated))
|
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,
|
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)]
|
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.
|
"""Convert SQL results into JSON friendly data structure.
|
||||||
|
|
||||||
This takes our state list and turns it into a JSON friendly data
|
This takes our state list and turns it into a JSON friendly data
|
||||||
@ -197,10 +205,9 @@ def states_to_json(hass, states, start_time, entity_id, filters=None):
|
|||||||
"""
|
"""
|
||||||
result = defaultdict(list)
|
result = defaultdict(list)
|
||||||
|
|
||||||
entity_ids = [entity_id] if entity_id is not None else None
|
|
||||||
|
|
||||||
# Get the states at the start time
|
# Get the states at the start time
|
||||||
timer_start = time.perf_counter()
|
timer_start = time.perf_counter()
|
||||||
|
if include_start_time_state:
|
||||||
for state in get_states(hass, start_time, entity_ids, filters=filters):
|
for state in get_states(hass, start_time, entity_ids, filters=filters):
|
||||||
state.last_changed = start_time
|
state.last_changed = start_time
|
||||||
state.last_updated = start_time
|
state.last_updated = start_time
|
||||||
@ -250,7 +257,7 @@ class HistoryPeriodView(HomeAssistantView):
|
|||||||
extra_urls = ['/api/history/period/{datetime}']
|
extra_urls = ['/api/history/period/{datetime}']
|
||||||
|
|
||||||
def __init__(self, filters):
|
def __init__(self, filters):
|
||||||
"""Initilalize the history period view."""
|
"""Initialize the history period view."""
|
||||||
self.filters = filters
|
self.filters = filters
|
||||||
|
|
||||||
@asyncio.coroutine
|
@asyncio.coroutine
|
||||||
@ -282,11 +289,14 @@ class HistoryPeriodView(HomeAssistantView):
|
|||||||
return self.json_message('Invalid end_time', HTTP_BAD_REQUEST)
|
return self.json_message('Invalid end_time', HTTP_BAD_REQUEST)
|
||||||
else:
|
else:
|
||||||
end_time = start_time + one_day
|
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(
|
result = yield from request.app['hass'].async_add_job(
|
||||||
get_significant_states, request.app['hass'], start_time, end_time,
|
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()
|
result = result.values()
|
||||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||||
elapsed = time.perf_counter() - timer_start
|
elapsed = time.perf_counter() - timer_start
|
||||||
|
87
homeassistant/components/history_graph.py
Normal file
87
homeassistant/components/history_graph.py
Normal 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
|
@ -145,6 +145,48 @@ class TestComponentHistory(unittest.TestCase):
|
|||||||
self.hass, zero, four, filters=history.Filters())
|
self.hass, zero, four, filters=history.Filters())
|
||||||
assert states == hist
|
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):
|
def test_get_significant_states_entity_id(self):
|
||||||
"""Test that only significant states are returned for one entity."""
|
"""Test that only significant states are returned for one entity."""
|
||||||
zero, four, states = self.record_states()
|
zero, four, states = self.record_states()
|
||||||
@ -154,7 +196,19 @@ class TestComponentHistory(unittest.TestCase):
|
|||||||
del states['script.can_cancel_this_one']
|
del states['script.can_cancel_this_one']
|
||||||
|
|
||||||
hist = history.get_significant_states(
|
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())
|
filters=history.Filters())
|
||||||
assert states == hist
|
assert states == hist
|
||||||
|
|
||||||
|
46
tests/components/test_history_graph.py
Normal file
46
tests/components/test_history_graph.py
Normal 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()
|
Loading…
x
Reference in New Issue
Block a user