mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 05:07:41 +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
|
||||
|
||||
|
||||
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
|
||||
|
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())
|
||||
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
|
||||
|
||||
|
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