mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 16:57:53 +00:00
Add Google Timelines to UI
This commit is contained in:
parent
3439f4bb93
commit
fbae2ef725
@ -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
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
@ -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>
|
@ -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>
|
||||
|
||||
|
@ -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));
|
||||
},
|
||||
|
@ -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();
|
||||
},
|
||||
|
@ -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>
|
||||
|
@ -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))
|
||||
|
@ -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. """
|
||||
|
@ -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):
|
||||
|
Loading…
x
Reference in New Issue
Block a user