Add Google Timelines to UI

This commit is contained in:
Paulus Schoutsen 2015-02-01 18:00:30 -08:00
parent 3439f4bb93
commit fbae2ef725
13 changed files with 289 additions and 62 deletions

View File

@ -1,2 +1,2 @@
""" DO NOT MODIFY. Auto-generated by build_frontend script """ """ DO NOT MODIFY. Auto-generated by build_frontend script """
VERSION = "212470c7842a8715f81fba76a3bd985f" VERSION = "954620894f13782f17ae7443f0df4ffc"

File diff suppressed because one or more lines are too long

View File

@ -21,6 +21,7 @@
"core-input": "Polymer/core-input#~0.5.4", "core-input": "Polymer/core-input#~0.5.4",
"core-icons": "polymer/core-icons#~0.5.4", "core-icons": "polymer/core-icons#~0.5.4",
"core-image": "polymer/core-image#~0.5.4", "core-image": "polymer/core-image#~0.5.4",
"core-style": "polymer/core-style#~0.5.4",
"paper-toast": "Polymer/paper-toast#~0.5.4", "paper-toast": "Polymer/paper-toast#~0.5.4",
"paper-dialog": "Polymer/paper-dialog#~0.5.4", "paper-dialog": "Polymer/paper-dialog#~0.5.4",
"paper-spinner": "Polymer/paper-spinner#~0.5.4", "paper-spinner": "Polymer/paper-spinner#~0.5.4",
@ -32,9 +33,9 @@
"paper-menu-button": "polymer/paper-menu-button#~0.5.4", "paper-menu-button": "polymer/paper-menu-button#~0.5.4",
"paper-dropdown": "polymer/paper-dropdown#~0.5.4", "paper-dropdown": "polymer/paper-dropdown#~0.5.4",
"paper-item": "polymer/paper-item#~0.5.4", "paper-item": "polymer/paper-item#~0.5.4",
"moment": "~2.8.4",
"core-style": "polymer/core-style#~0.5.4",
"paper-slider": "polymer/paper-slider#~0.5.4", "paper-slider": "polymer/paper-slider#~0.5.4",
"color-picker-element": "~0.0.2" "moment": "~2.8.4",
"color-picker-element": "~0.0.2",
"google-apis": "GoogleWebComponents/google-apis#~0.4.2"
} }
} }

View File

@ -36,7 +36,7 @@
stateObjChanged: function() { stateObjChanged: function() {
this.recentStates = null; this.recentStates = null;
this.api.call_api('GET', 'history/' + this.stateObj.entity_id + '/recent_states', {}, this.newStates.bind(this)); this.api.call_api('GET', 'history/entity/' + this.stateObj.entity_id + '/recent_states', {}, this.newStates.bind(this));
}, },
newStates: function(states) { newStates: function(states) {

View File

@ -0,0 +1,128 @@
<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="../bower_components/google-apis/google-jsapi.html">
<polymer-element name="state-timeline" attributes="stateObj api">
<template>
<style>
:host {
display: block;
}
</style>
<google-jsapi on-api-load="{{googleApiLoaded}}"></google-jsapi>
<div id="timeline" style='width: 100%; height: auto;'></div>
</template>
<script>
Polymer({
apiLoaded: false,
stateData: null,
googleApiLoaded: function() {
google.load("visualization", "1", {
packages: ["timeline"],
callback: function() {
this.apiLoaded = true;
this.drawChart();
}.bind(this)
});
},
stateObjChanged: function(oldVal, newVal) {
// update data if we get a new stateObj
if (!oldVal || (newVal && oldVal.entity_id === newVal.entity_id)) {
this.drawChart();
} else {
this.fetchData();
}
},
fetchData: function() {
if (!this.api) {
return;
}
this.stateData = null;
var url = 'history/period';
if (this.stateObj) {
url += '?filter_entity_id=' + this.stateObj.entity_id;
}
this.api.call_api('GET', url, {}, function(stateData) {
this.stateData = stateData;
this.drawChart();
}.bind(this)
);
},
drawChart: function() {
if (!this.apiLoaded || this.stateData === null) {
return;
}
var container = this.$.timeline;
var chart = new google.visualization.Timeline(container);
var dataTable = new google.visualization.DataTable();
dataTable.addColumn({ type: 'string', id: 'Entity' });
dataTable.addColumn({ type: 'string', id: 'State' });
dataTable.addColumn({ type: 'date', id: 'Start' });
dataTable.addColumn({ type: 'date', id: 'End' });
var stateTimeToDate = function(time) {
if (!time) return new Date();
return ha.util.parseTime(time).toDate();
};
var addRow = function(baseState, state, tillState) {
tillState = tillState || {};
dataTable.addRow([
baseState.entityDisplay, state.state,
stateTimeToDate(state.last_changed),
stateTimeToDate(tillState.last_changed)]);
};
// this.stateData is a list of lists of sorted state objects
this.stateData.forEach(function(stateInfo) {
var baseState = new this.api.State(stateInfo[0], this.api);
var prevRow = null;
stateInfo.forEach(function(state) {
if (prevRow !== null && state.state !== prevRow.state) {
addRow(baseState, prevRow, state);
prevRow = state;
} else if (prevRow === null) {
prevRow = state;
}
});
addRow(baseState, prevRow, null);
}.bind(this));
chart.draw(dataTable, {
height: 55 + this.stateData.length * 42,
// interactive properties require CSS, the JS api puts it on the document
// instead of inside our Shadow DOM.
enableInteractivity: false,
timeline: {
showRowLabels: this.stateData.length > 1
},
hAxis: {
format: 'H:mm'
},
// colors: ['#CCC', '#03a9f4']
});
},
});
</script>
</polymer-element>

View File

@ -0,0 +1,37 @@
<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="ha-action-dialog.html">
<link rel="import" href="../components/state-timeline.html">
<polymer-element name="history-dialog" attributes="api">
<template>
<ha-action-dialog id="dialog" heading="History">
<style>
#timeline {
width: 614px;
height: auto;
display: block;
}
</style>
<state-timeline id='timeline' api="{{api}}"></state-timeline>
</ha-action-dialog>
</template>
<script>
Polymer({
show: function() {
this.$.dialog.toggle();
this.job('repositionDialogAfterRender', function() {
this.$.timeline.fetchData();
this.job('repositionDialogAfterRender', function() {
this.$.dialog.resizeHandler();
}.bind(this), 1000);
}.bind(this));
},
});
</script>
</polymer-element>

View File

@ -2,6 +2,7 @@
<link rel="import" href="ha-action-dialog.html"> <link rel="import" href="ha-action-dialog.html">
<link rel="import" href="../cards/state-card-content.html"> <link rel="import" href="../cards/state-card-content.html">
<link rel="import" href="../components/state-timeline.html">
<link rel="import" href="../more-infos/more-info-content.html"> <link rel="import" href="../more-infos/more-info-content.html">
<polymer-element name="more-info-dialog" attributes="api"> <polymer-element name="more-info-dialog" attributes="api">
@ -17,6 +18,7 @@
<div> <div>
<state-card-content stateObj="{{stateObj}}" api="{{api}}" class='title-card'> <state-card-content stateObj="{{stateObj}}" api="{{api}}" class='title-card'>
</state-card-content> </state-card-content>
<state-timeline stateObj="{{stateObj}}" api="{{api}}"></state-timeline>
<more-info-content stateObj="{{stateObj}}" api="{{api}}"></more-info-content> <more-info-content stateObj="{{stateObj}}" api="{{api}}"></more-info-content>
</div> </div>

View File

@ -7,6 +7,7 @@
<link rel="import" href="dialogs/service-call-dialog.html"> <link rel="import" href="dialogs/service-call-dialog.html">
<link rel="import" href="dialogs/state-set-dialog.html"> <link rel="import" href="dialogs/state-set-dialog.html">
<link rel="import" href="dialogs/more-info-dialog.html"> <link rel="import" href="dialogs/more-info-dialog.html">
<link rel="import" href="dialogs/history-dialog.html">
<script> <script>
var ha = {}; var ha = {};
@ -37,6 +38,7 @@
<service-call-dialog id="serviceDialog" api={{api}}></service-call-dialog> <service-call-dialog id="serviceDialog" api={{api}}></service-call-dialog>
<state-set-dialog id="stateSetDialog" api={{api}}></state-set-dialog> <state-set-dialog id="stateSetDialog" api={{api}}></state-set-dialog>
<more-info-dialog id="moreInfoDialog" api={{api}}></more-info-dialog> <more-info-dialog id="moreInfoDialog" api={{api}}></more-info-dialog>
<history-dialog id="historyDialog" api={{api}}></history-dialog>
</template> </template>
<script> <script>
var domainsWithCard = ['thermostat', 'configurator']; var domainsWithCard = ['thermostat', 'configurator'];
@ -128,6 +130,10 @@
events: [], events: [],
stateUpdateTimeout: null, stateUpdateTimeout: null,
// available classes
State: State,
// Polymer lifecycle methods
created: function() { created: function() {
this.api = this; this.api = this;
@ -451,6 +457,10 @@
}, },
// show dialogs // show dialogs
showHistoryDialog: function() {
this.$.historyDialog.show();
},
showmoreInfoDialog: function(entityId) { showmoreInfoDialog: function(entityId) {
this.$.moreInfoDialog.show(this.getState(entityId)); this.$.moreInfoDialog.show(this.getState(entityId));
}, },

View File

@ -1,5 +1,6 @@
<link rel="import" href="bower_components/core-header-panel/core-header-panel.html"> <link rel="import" href="bower_components/core-header-panel/core-header-panel.html">
<link rel="import" href="bower_components/core-toolbar/core-toolbar.html"> <link rel="import" href="bower_components/core-toolbar/core-toolbar.html">
<link rel="import" href="bower_components/core-icon/core-icon.html">
<link rel="import" href="bower_components/paper-tabs/paper-tabs.html"> <link rel="import" href="bower_components/paper-tabs/paper-tabs.html">
<link rel="import" href="bower_components/paper-tabs/paper-tab.html"> <link rel="import" href="bower_components/paper-tabs/paper-tab.html">
<link rel="import" href="bower_components/paper-icon-button/paper-icon-button.html"> <link rel="import" href="bower_components/paper-icon-button/paper-icon-button.html">
@ -74,21 +75,34 @@
<div flex>Home Assistant</div> <div flex>Home Assistant</div>
<paper-icon-button icon="refresh" <paper-icon-button icon="refresh"
on-click="{{handleRefreshClick}}"></paper-icon-button> on-click="{{handleRefreshClick}}"></paper-icon-button>
<paper-icon-button icon="settings-remote" <paper-icon-button icon="assessment"
on-click="{{handleServiceClick}}"></paper-icon-button> on-click="{{handleHistoryClick}}"></paper-icon-button>
<paper-menu-button> <paper-menu-button>
<paper-icon-button icon="more-vert" noink></paper-icon-button> <paper-icon-button icon="more-vert" noink></paper-icon-button>
<paper-dropdown halign="right" duration="200" class="dropdown"> <paper-dropdown halign="right" duration="200" class="dropdown">
<core-menu class="menu"> <core-menu class="menu">
<paper-item> <paper-item>
<a on-click={{handleAddStateClick}}>Set State</a> <a on-click={{handleServiceClick}}>
<!-- <core-icon icon="settings-remote"></core-icon> -->
Call Service
</a>
</paper-item> </paper-item>
<paper-item> <paper-item>
<a on-click={{handleEventClick}}>Trigger Event</a> <a on-click={{handleAddStateClick}}>
Set State
</a>
</paper-item> </paper-item>
<paper-item> <paper-item>
<a on-click={{handleLogOutClick}}>Log Out</a> <a on-click={{handleEventClick}}>
Trigger Event
</a>
</paper-item>
<paper-item>
<a on-click={{handleLogOutClick}}>
<!-- <core-icon icon="exit-to-app"></core-icon> -->
Log Out
</a>
</paper-item> </paper-item>
</core-menu> </core-menu>
</paper-dropdown> </paper-dropdown>
@ -147,6 +161,10 @@
this.api.fetchAll(); this.api.fetchAll();
}, },
handleHistoryClick: function() {
this.api.showHistoryDialog();
},
handleEventClick: function() { handleEventClick: function() {
this.api.showFireEventDialog(); this.api.showFireEventDialog();
}, },

View File

@ -1,8 +1,6 @@
<link rel="import" href="../bower_components/polymer/polymer.html"> <link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="../bower_components/core-style/core-style.html"> <link rel="import" href="../bower_components/core-style/core-style.html">
<link rel="import" href="../components/recent-states.html">
<polymer-element name="more-info-default" attributes="stateObj api"> <polymer-element name="more-info-default" attributes="stateObj api">
<template> <template>
<core-style ref='ha-key-value-table'></core-style> <core-style ref='ha-key-value-table'></core-style>
@ -24,23 +22,12 @@
</div> </div>
</div> </div>
</template> </template>
<template if="{{hasHistoryComponent}}">
<h4>Recent states</h4>
<recent-states api="{{api}}" stateObj="{{stateObj}}"></recent-states>
</template>
</div> </div>
</template> </template>
<script> <script>
Polymer({ Polymer({
hasHistoryComponent: false,
getKeys: function(obj) { getKeys: function(obj) {
return Object.keys(obj || {}); return Object.keys(obj || {});
},
apiChanged: function(oldVal, newVal) {
this.hasHistoryComponent = this.api && this.api.hasComponent('history');
} }
}); });
</script> </script>

View File

@ -5,6 +5,8 @@ homeassistant.components.history
Provide pre-made queries on top of the recorder component. Provide pre-made queries on top of the recorder component.
""" """
import re import re
from datetime import datetime, timedelta
from itertools import groupby
import homeassistant.components.recorder as recorder import homeassistant.components.recorder as recorder
@ -14,23 +16,55 @@ DEPENDENCIES = ['recorder', 'http']
def last_5_states(entity_id): def last_5_states(entity_id):
""" Return the last 5 states for entity_id. """ """ Return the last 5 states for entity_id. """
entity_id = entity_id.lower()
query = """ query = """
SELECT * FROM states WHERE entity_id=? AND SELECT * FROM states WHERE entity_id=? AND
last_changed=last_updated AND {} last_changed=last_updated
ORDER BY last_changed DESC LIMIT 0, 5 ORDER BY last_changed DESC LIMIT 0, 5
""".format(recorder.limit_to_run()) """
return recorder.query_states(query, (entity_id, )) return recorder.query_states(query, (entity_id, ))
def state_changes_during_period(start_time, end_time=None, entity_id=None):
"""
Return states changes during period start_time - end_time.
Currently does _not_ include how the states where at exactly start_time.
"""
where = "last_changed=last_updated AND last_changed > ? "
data = [start_time]
if end_time is not None:
where += "AND last_changed < ? "
data.append(end_time)
if entity_id is not None:
where += "AND entity_id = ? "
data.append(entity_id.lower())
query = ("SELECT * FROM states WHERE {} "
"ORDER BY entity_id, last_changed ASC").format(where)
states = recorder.query_states(query, data)
return [list(group) for _, group in
groupby(states, lambda state: state.entity_id)]
def setup(hass, config): def setup(hass, config):
""" Setup history hooks. """ """ Setup history hooks. """
hass.http.register_path( hass.http.register_path(
'GET', 'GET',
re.compile( re.compile(
r'/api/history/(?P<entity_id>[a-zA-Z\._0-9]+)/recent_states'), r'/api/history/entity/(?P<entity_id>[a-zA-Z\._0-9]+)/'
r'recent_states'),
_api_last_5_states) _api_last_5_states)
hass.http.register_path(
'GET', re.compile(r'/api/history/period'), _api_history_period)
return True return True
@ -40,3 +74,14 @@ def _api_last_5_states(handler, path_match, data):
entity_id = path_match.group('entity_id') entity_id = path_match.group('entity_id')
handler.write_json(list(last_5_states(entity_id))) handler.write_json(list(last_5_states(entity_id)))
def _api_history_period(handler, path_match, data):
""" Return history over a period of time. """
# 1 day for now..
start_time = datetime.now() - timedelta(seconds=86400)
entity_id = data.get('filter_entity_id')
handler.write_json(
state_changes_during_period(start_time, entity_id=entity_id))

View File

@ -84,32 +84,19 @@ def limit_to_run(point_in_time=None):
""" """
_verify_instance() _verify_instance()
end_event = None
# Targetting current run # Targetting current run
if point_in_time is None: if point_in_time is None:
return "created >= {}".format( return "created >= {} ".format(
_adapt_datetime(_INSTANCE.recording_start)) _adapt_datetime(_INSTANCE.recording_start))
start_event = query( raise NotImplementedError()
("SELECT * FROM events WHERE event_type = ? AND created < ? "
"ORDER BY created DESC LIMIT 0, 1"),
(EVENT_HOMEASSISTANT_START, point_in_time))[0]
end_query = query(
("SELECT * FROM events WHERE event_type = ? AND created > ? "
"ORDER BY created ASC LIMIT 0, 1"),
(EVENT_HOMEASSISTANT_START, point_in_time))
if end_query: def recording_start():
end_event = end_query[0] """ Return when the recorder started. """
_verify_instance()
where_part = "created >= {}".format(start_event['created']) return _INSTANCE.recording_start
if end_event is None:
return where_part
else:
return "{} and created < {}".format(where_part, end_event['created'])
def setup(hass, config): def setup(hass, config):
@ -183,13 +170,13 @@ class Recorder(threading.Thread):
info = (entity_id, '', "{}", now, now, now) info = (entity_id, '', "{}", now, now, now)
else: else:
info = ( info = (
entity_id, state.state, json.dumps(state.attributes), entity_id.lower(), state.state, json.dumps(state.attributes),
state.last_changed, state.last_updated, now) state.last_changed, state.last_updated, now)
self.query( self.query(
"insert into states (" "INSERT INTO states ("
"entity_id, state, attributes, last_changed, last_updated," "entity_id, state, attributes, last_changed, last_updated,"
"created) values (?, ?, ?, ?, ?, ?)", info) "created) VALUES (?, ?, ?, ?, ?, ?)", info)
def record_event(self, event): def record_event(self, event):
""" Save an event to the database. """ """ Save an event to the database. """
@ -199,9 +186,9 @@ class Recorder(threading.Thread):
) )
self.query( self.query(
"insert into events (" "INSERT INTO events ("
"event_type, event_data, origin, created" "event_type, event_data, origin, created"
") values (?, ?, ?, ?)", info) ") VALUES (?, ?, ?, ?)", info)
def query(self, sql_query, data=None, return_value=None): def query(self, sql_query, data=None, return_value=None):
""" Query the database. """ """ Query the database. """

View File

@ -261,7 +261,17 @@ class JSONEncoder(json.JSONEncoder):
if isinstance(obj, (ha.State, ha.Event)): if isinstance(obj, (ha.State, ha.Event)):
return obj.as_dict() return obj.as_dict()
return json.JSONEncoder.default(self, obj) try:
return json.JSONEncoder.default(self, obj)
except TypeError:
# If the JSON serializer couldn't serialize it
# it might be a generator, convert it to a list
try:
return [json.JSONEncoder.default(self, child_obj)
for child_obj in obj]
except TypeError:
# Ok, we're lost, cause the original error
return json.JSONEncoder.default(self, obj)
def validate_api(api): def validate_api(api):