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 """
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-icons": "polymer/core-icons#~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-dialog": "Polymer/paper-dialog#~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-dropdown": "polymer/paper-dropdown#~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",
"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() {
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) {

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

View File

@ -7,6 +7,7 @@
<link rel="import" href="dialogs/service-call-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/history-dialog.html">
<script>
var ha = {};
@ -37,6 +38,7 @@
<service-call-dialog id="serviceDialog" api={{api}}></service-call-dialog>
<state-set-dialog id="stateSetDialog" api={{api}}></state-set-dialog>
<more-info-dialog id="moreInfoDialog" api={{api}}></more-info-dialog>
<history-dialog id="historyDialog" api={{api}}></history-dialog>
</template>
<script>
var domainsWithCard = ['thermostat', 'configurator'];
@ -128,6 +130,10 @@
events: [],
stateUpdateTimeout: null,
// available classes
State: State,
// Polymer lifecycle methods
created: function() {
this.api = this;
@ -451,6 +457,10 @@
},
// show dialogs
showHistoryDialog: function() {
this.$.historyDialog.show();
},
showmoreInfoDialog: function(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-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-tab.html">
<link rel="import" href="bower_components/paper-icon-button/paper-icon-button.html">
@ -74,21 +75,34 @@
<div flex>Home Assistant</div>
<paper-icon-button icon="refresh"
on-click="{{handleRefreshClick}}"></paper-icon-button>
<paper-icon-button icon="settings-remote"
on-click="{{handleServiceClick}}"></paper-icon-button>
<paper-icon-button icon="assessment"
on-click="{{handleHistoryClick}}"></paper-icon-button>
<paper-menu-button>
<paper-icon-button icon="more-vert" noink></paper-icon-button>
<paper-dropdown halign="right" duration="200" class="dropdown">
<core-menu class="menu">
<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>
<a on-click={{handleEventClick}}>Trigger Event</a>
<a on-click={{handleAddStateClick}}>
Set State
</a>
</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>
</core-menu>
</paper-dropdown>
@ -147,6 +161,10 @@
this.api.fetchAll();
},
handleHistoryClick: function() {
this.api.showHistoryDialog();
},
handleEventClick: function() {
this.api.showFireEventDialog();
},

View File

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

View File

@ -5,6 +5,8 @@ homeassistant.components.history
Provide pre-made queries on top of the recorder component.
"""
import re
from datetime import datetime, timedelta
from itertools import groupby
import homeassistant.components.recorder as recorder
@ -14,23 +16,55 @@ DEPENDENCIES = ['recorder', 'http']
def last_5_states(entity_id):
""" Return the last 5 states for entity_id. """
entity_id = entity_id.lower()
query = """
SELECT * FROM states WHERE entity_id=? AND
last_changed=last_updated AND {}
last_changed=last_updated
ORDER BY last_changed DESC LIMIT 0, 5
""".format(recorder.limit_to_run())
"""
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):
""" Setup history hooks. """
hass.http.register_path(
'GET',
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)
hass.http.register_path(
'GET', re.compile(r'/api/history/period'), _api_history_period)
return True
@ -40,3 +74,14 @@ def _api_last_5_states(handler, path_match, data):
entity_id = path_match.group('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()
end_event = None
# Targetting current run
if point_in_time is None:
return "created >= {}".format(
return "created >= {} ".format(
_adapt_datetime(_INSTANCE.recording_start))
start_event = query(
("SELECT * FROM events WHERE event_type = ? AND created < ? "
"ORDER BY created DESC LIMIT 0, 1"),
(EVENT_HOMEASSISTANT_START, point_in_time))[0]
raise NotImplementedError()
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:
end_event = end_query[0]
def recording_start():
""" Return when the recorder started. """
_verify_instance()
where_part = "created >= {}".format(start_event['created'])
if end_event is None:
return where_part
else:
return "{} and created < {}".format(where_part, end_event['created'])
return _INSTANCE.recording_start
def setup(hass, config):
@ -183,13 +170,13 @@ class Recorder(threading.Thread):
info = (entity_id, '', "{}", now, now, now)
else:
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)
self.query(
"insert into states ("
"INSERT INTO states ("
"entity_id, state, attributes, last_changed, last_updated,"
"created) values (?, ?, ?, ?, ?, ?)", info)
"created) VALUES (?, ?, ?, ?, ?, ?)", info)
def record_event(self, event):
""" Save an event to the database. """
@ -199,9 +186,9 @@ class Recorder(threading.Thread):
)
self.query(
"insert into events ("
"INSERT INTO events ("
"event_type, event_data, origin, created"
") values (?, ?, ?, ?)", info)
") VALUES (?, ?, ?, ?)", info)
def query(self, sql_query, data=None, return_value=None):
""" Query the database. """

View File

@ -261,7 +261,17 @@ class JSONEncoder(json.JSONEncoder):
if isinstance(obj, (ha.State, ha.Event)):
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):