Add support for history_graph component (#432)

* Add support for history_graph component

* Change dev 3s to intended 60s

* Address comments

* Make card header consistent
This commit is contained in:
Andrey 2017-10-05 19:15:17 +03:00 committed by Paulus Schoutsen
parent 890dbc6ad7
commit b0791abb9a
11 changed files with 654 additions and 321 deletions

View File

@ -2,6 +2,7 @@
<link rel='import' href='./ha-camera-card.html'> <link rel='import' href='./ha-camera-card.html'>
<link rel='import' href='./ha-entities-card.html'> <link rel='import' href='./ha-entities-card.html'>
<link rel='import' href='./ha-history_graph-card.html'>
<link rel='import' href='./ha-introduction-card.html'> <link rel='import' href='./ha-introduction-card.html'>
<link rel='import' href='./ha-media_player-card.html'> <link rel='import' href='./ha-media_player-card.html'>
<link rel='import' href='./ha-weather-card.html'> <link rel='import' href='./ha-weather-card.html'>

View File

@ -0,0 +1,102 @@
<link rel='import' href='../../bower_components/polymer/polymer-element.html'>
<link rel="import" href="../../bower_components/paper-card/paper-card.html">
<link rel="import" href="../components/state-history-charts.html">
<link rel="import" href="../data/ha-state-history-data.html">
<link rel='import' href='../util/hass-mixins.html'>
<dom-module id='ha-history_graph-card'>
<template>
<style>
paper-card:not([dialog]) .content {
padding: 0 16px 16px;
}
paper-card {
width: 100%;
}
.header {
@apply(--paper-font-headline);
line-height: 40px;
color: var(--primary-text-color);
padding: 20px 16px 12px;
@apply(--paper-font-common-nowrap);
}
paper-card[dialog] .header {
padding-top: 0;
padding-left: 0;
}
</style>
<ha-state-history-data
hass='[[hass]]'
filter-type='recent-entity'
entity-id='[[computeHistoryEntities(stateObj)]]'
data='{{stateHistory}}'
is-loading='{{stateHistoryLoading}}'
cache-config='[[computeCacheConfig(stateObj)]]'
></ha-state-history-data>
<paper-card dialog$='[[inDialog]]'
on-tap='cardTapped'
elevation='[[computeElevation(inDialog)]]'>
<div class='header'>[[computeTitle(stateObj)]]</div>
<div class='content'>
<state-history-charts
history-data="[[stateHistory]]"
is-loading-data="[[stateHistoryLoading]]"
up-to-now
no-single>
</state-history-charts>
</div>
</paper-card>
</template>
</dom-module>
<script>
class HaHistoryGraphCard extends window.hassMixins.EventsMixin(Polymer.Element) {
static get is() { return 'ha-history_graph-card'; }
static get properties() {
return {
hass: Object,
stateObj: Object,
inDialog: {
type: Boolean,
value: false,
},
stateHistory: Object,
stateHistoryLoading: Boolean,
};
}
computeTitle(stateObj) {
return window.hassUtil.computeStateName(stateObj);
}
computeContentClass(inDialog) {
return inDialog ? '' : 'content';
}
computeHistoryEntities(stateObj) {
return stateObj.attributes.entity_id;
}
computeCacheConfig(stateObj) {
return {
refresh: stateObj.attributes.refresh || 0,
cacheKey: stateObj.entity_id,
hoursToShow: (stateObj && stateObj.attributes.hours_to_show) || 24,
};
}
computeElevation(inDialog) {
return inDialog ? 0 : 1;
}
cardTapped(ev) {
const mq = window.matchMedia('(min-width: 610px) and (min-height: 550px)');
if (mq.matches) {
ev.stopPropagation();
this.fire('hass-more-info', { entityId: this.stateObj.entity_id });
}
}
}
customElements.define(HaHistoryGraphCard.is, HaHistoryGraphCard);
</script>

View File

@ -83,6 +83,7 @@
// mapping domain to size of the card. // mapping domain to size of the card.
var DOMAINS_WITH_CARD = { var DOMAINS_WITH_CARD = {
camera: 4, camera: 4,
history_graph: 4,
media_player: 3, media_player: 3,
persistent_notification: 0, persistent_notification: 0,
weather: 4, weather: 4,

View File

@ -1,4 +1,5 @@
<link rel="import" href="../../bower_components/polymer/polymer.html"> <link rel="import" href="../../bower_components/polymer/polymer.html">
<link rel="import" href="../../bower_components/iron-resizable-behavior/iron-resizable-behavior.html">
<script> <script>
(function () { (function () {
@ -20,52 +21,52 @@
return !isNaN(parsed) && isFinite(parsed) ? parsed : null; return !isNaN(parsed) && isFinite(parsed) ? parsed : null;
} }
Polymer({ class StateHistoryChartLine extends
is: 'state-history-chart-line', Polymer.mixinBehaviors([Polymer.IronResizableBehavior], Polymer.Element) {
static get is() { return 'state-history-chart-line'; }
static get properties() {
return {
data: {
type: Object,
},
properties: { unit: {
data: { type: String,
type: Object, },
observer: 'dataChanged',
},
unit: { isSingleDevice: {
type: String, type: Boolean,
}, value: false,
},
isSingleDevice: { endTime: {
type: Boolean, type: Object,
value: false, },
},
isAttached: { chartEngine: {
type: Boolean, type: Object,
value: false, },
observer: 'dataChanged', };
}, }
endTime: { static get observers() {
type: Object, return ['dataChanged(data, endTime)'];
}, }
chartEngine: { connectedCallback() {
type: Object, super.connectedCallback();
}, this._isAttached = true;
},
created: function () {
this.style.display = 'block';
},
attached: function () {
this.isAttached = true;
},
dataChanged: function () {
this.drawChart(); this.drawChart();
}, this.addEventListener('iron-resize', () => {
this.async(this.drawChart, 10);
});
}
drawChart: function () { dataChanged() {
this.drawChart();
}
drawChart() {
var unit = this.unit; var unit = this.unit;
var deviceStates = this.data; var deviceStates = this.data;
var options; var options;
@ -75,7 +76,7 @@
var finalDataTable; var finalDataTable;
var daysDelta; var daysDelta;
if (!this.isAttached) { if (!this._isAttached) {
return; return;
} }
@ -117,12 +118,12 @@
} }
startTime = new Date(Math.min.apply(null, deviceStates.map(function (states) { startTime = new Date(Math.min.apply(null, deviceStates.map(function (states) {
return new Date(states[0].last_changed); return new Date(states.states[0].last_changed);
}))); })));
endTime = this.endTime || endTime = this.endTime ||
new Date(Math.max.apply(null, deviceStates.map(states => new Date(Math.max.apply(null, deviceStates.map(states =>
new Date(states[states.length - 1].last_changed) new Date(states.states[states.states.length - 1].last_changed)
))); )));
if (endTime > new Date()) { if (endTime > new Date()) {
endTime = new Date(); endTime = new Date();
@ -139,9 +140,8 @@
} }
dataTables = deviceStates.map(function (states) { dataTables = deviceStates.map(function (states) {
var last = states[states.length - 1]; var domain = states.domain;
var domain = window.hassUtil.computeDomain(last); var name = states.name;
var name = window.hassUtil.computeStateName(last);
var data = []; var data = [];
var dataTable = new window.google.visualization.DataTable(); var dataTable = new window.google.visualization.DataTable();
// array containing [time, value1, value2, etc] // array containing [time, value1, value2, etc]
@ -174,7 +174,7 @@
if (domain === 'thermostat' || domain === 'climate') { if (domain === 'thermostat' || domain === 'climate') {
// We differentiate between thermostats that have a target temperature // We differentiate between thermostats that have a target temperature
// range versus ones that have just a target temperature // range versus ones that have just a target temperature
hasTargetRange = states.reduce( hasTargetRange = states.states.reduce(
function (cum, cur) { function (cum, cur) {
return cum || cur.attributes.target_temp_high !== cur.attributes.target_temp_low; return cum || cur.attributes.target_temp_high !== cur.attributes.target_temp_low;
}, false); }, false);
@ -192,7 +192,7 @@
var targetHigh = saveParseFloat(state.attributes.target_temp_high); var targetHigh = saveParseFloat(state.attributes.target_temp_high);
var targetLow = saveParseFloat(state.attributes.target_temp_low); var targetLow = saveParseFloat(state.attributes.target_temp_low);
pushData( pushData(
[new Date(state.last_updated), curTemp, targetHigh, targetLow], [new Date(state.last_changed), curTemp, targetHigh, targetLow],
noInterpolations); noInterpolations);
}; };
} else { } else {
@ -203,18 +203,18 @@
processState = function (state) { processState = function (state) {
var curTemp = saveParseFloat(state.attributes.current_temperature); var curTemp = saveParseFloat(state.attributes.current_temperature);
var target = saveParseFloat(state.attributes.temperature); var target = saveParseFloat(state.attributes.temperature);
pushData([new Date(state.last_updated), curTemp, target], noInterpolations); pushData([new Date(state.last_changed), curTemp, target], noInterpolations);
}; };
} }
states.forEach(processState); states.states.forEach(processState);
} else { } else {
dataTable.addColumn('number', name); dataTable.addColumn('number', name);
// Only disable interpolation for sensors // Only disable interpolation for sensors
noInterpolations = domain !== 'sensor' && [true]; noInterpolations = domain !== 'sensor' && [true];
states.forEach(function (state) { states.states.forEach(function (state) {
var value = saveParseFloat(state.state); var value = saveParseFloat(state.state);
pushData([new Date(state.last_changed), value], noInterpolations); pushData([new Date(state.last_changed), value], noInterpolations);
}); });
@ -241,7 +241,8 @@
} }
this.chartEngine.draw(finalDataTable, options); this.chartEngine.draw(finalDataTable, options);
}, }
}); }
customElements.define(StateHistoryChartLine.is, StateHistoryChartLine);
}()); }());
</script> </script>

View File

@ -1,47 +1,38 @@
<link rel="import" href="../../bower_components/polymer/polymer.html"> <link rel="import" href="../../bower_components/polymer/polymer-element.html">
<link rel="import" href="../../bower_components/iron-resizable-behavior/iron-resizable-behavior.html">
<style>
div.charts-tooltip {
z-index: 200 !important;
}
</style>
<script> <script>
class StateHistoryChartTimeline extends
Polymer.mixinBehaviors([Polymer.IronResizableBehavior], Polymer.Element) {
static get is() { return 'state-history-chart-timeline'; }
static get properties() {
return {
data: {
type: Object,
},
noSingle: Boolean,
endTime: Date,
};
}
Polymer({ static get observers() {
is: 'state-history-chart-timeline', return ['dataChanged(data, endTime)'];
}
properties: { connectedCallback() {
data: { super.connectedCallback();
type: Object, this._isAttached = true;
observer: 'dataChanged',
},
endTime: {
type: Object,
},
isAttached: {
type: Boolean,
value: false,
observer: 'dataChanged',
},
},
created: function () {
this.style.display = 'block';
},
attached: function () {
this.isAttached = true;
},
dataChanged: function () {
this.drawChart(); this.drawChart();
}, this.addEventListener('iron-resize', () => {
this.async(this.drawChart, 10);
});
}
drawChart: function () { dataChanged() {
var root = Polymer.dom(this); this.drawChart();
}
drawChart() {
var stateHistory = this.data; var stateHistory = this.data;
var chart; var chart;
var dataTable; var dataTable;
@ -51,12 +42,12 @@ Polymer({
var format; var format;
var daysDelta; var daysDelta;
if (!this.isAttached) { if (!this._isAttached) {
return; return;
} }
while (root.node.lastChild) { while (this.lastChild) {
root.node.removeChild(root.node.lastChild); this.removeChild(this.lastChild);
} }
if (!stateHistory || stateHistory.length === 0) { if (!stateHistory || stateHistory.length === 0) {
@ -79,14 +70,15 @@ Polymer({
startTime = new Date( startTime = new Date(
stateHistory.reduce( stateHistory.reduce(
function (minTime, stateInfo) { function (minTime, stateInfo) {
return Math.min(minTime, new Date(stateInfo[0].last_changed)); return Math.min(minTime, new Date(stateInfo.data[0].last_changed));
}, new Date())); }, new Date()));
// end time is Math.max(startTime, last_event) // end time is Math.max(startTime, last_event)
endTime = this.endTime || endTime = this.endTime ||
new Date(stateHistory.reduce( new Date(stateHistory.reduce(
function (maxTime, stateInfo) { function (maxTime, stateInfo) {
return Math.max(maxTime, new Date(stateInfo[stateInfo.length - 1].last_changed)); return Math.max(maxTime,
new Date(stateInfo.data[stateInfo.data.length - 1].last_changed));
}, startTime)); }, startTime));
if (endTime > new Date()) { if (endTime > new Date()) {
@ -111,11 +103,11 @@ Polymer({
var prevState = null; var prevState = null;
var prevLastChanged = null; var prevLastChanged = null;
if (stateInfo.length === 0) return; if (stateInfo.data.length === 0) return;
entityDisplay = window.hassUtil.computeStateName(stateInfo[0]); entityDisplay = stateInfo.name;
stateInfo.forEach(function (state) { stateInfo.data.forEach(function (state) {
var timeStamp = new Date(state.last_changed); var timeStamp = new Date(state.last_changed);
if (timeStamp > endTime) { if (timeStamp > endTime) {
// Drop datapoints that are after the requested endTime. This could happen if // Drop datapoints that are after the requested endTime. This could happen if
@ -145,13 +137,14 @@ Polymer({
height: 55 + (numTimelines * 42), height: 55 + (numTimelines * 42),
timeline: { timeline: {
showRowLabels: stateHistory.length > 1, showRowLabels: this.noSingle || stateHistory.length > 1,
}, },
hAxis: { hAxis: {
format: format format: format
}, },
}); });
}, }
}); }
customElements.define(StateHistoryChartTimeline.is, StateHistoryChartTimeline);
</script> </script>

View File

@ -1,4 +1,4 @@
<link rel="import" href="../../bower_components/polymer/polymer.html"> <link rel="import" href="../../bower_components/polymer/polymer-element.html">
<link rel="import" href="../../bower_components/paper-spinner/paper-spinner.html"> <link rel="import" href="../../bower_components/paper-spinner/paper-spinner.html">
<link rel="import" href="../../bower_components/google-apis/google-legacy-loader.html"> <link rel="import" href="../../bower_components/google-apis/google-legacy-loader.html">
@ -14,6 +14,14 @@
display: block; display: block;
} }
.google-visualization-tooltip {
z-index: 200;
}
state-history-chart-timeline, state-history-chart-line {
display: block;
}
.loading-container { .loading-container {
text-align: center; text-align: center;
padding: 8px; padding: 8px;
@ -40,15 +48,16 @@
<state-history-chart-timeline <state-history-chart-timeline
data='[[historyData.timeline]]' data='[[historyData.timeline]]'
end-time='[[_computeEndTime(endTime, upToNow)]]'> end-time='[[_computeEndTime(endTime, upToNow, historyData)]]'
no-single='[[noSingle]]'>
</state-history-chart-timeline> </state-history-chart-timeline>
<template is='dom-repeat' items='[[historyData.line]]'> <template is='dom-repeat' items='[[historyData.line]]'>
<state-history-chart-line <state-history-chart-line
unit='[[item.unit]]' unit='[[item.unit]]'
data='[[item.data]]' data='[[item.data]]'
is-single-device='[[_computeIsSingleLineChart(historyData)]]' is-single-device='[[_computeIsSingleLineChart(historyData, noSingle)]]'
end-time='[[_computeEndTime(endTime, upToNow)]]'> end-time='[[_computeEndTime(endTime, upToNow, historyData)]]'>
</state-history-chart-line> </state-history-chart-line>
</template> </template>
</template> </template>
@ -56,65 +65,67 @@
</dom-module> </dom-module>
<script> <script>
Polymer({ class StateHistoryCharts extends Polymer.Element {
is: 'state-history-charts', static get is() { return 'state-history-charts'; }
static get properties() {
return {
historyData: {
type: Object,
value: null,
},
properties: { isLoadingData: {
historyData: { type: Boolean,
type: Object, value: true,
value: null, },
},
isLoadingData: { endTime: {
type: Boolean, type: Object,
value: true, },
},
endTime: { upToNow: Boolean,
type: Object, noSingle: Boolean,
},
upToNow: { _apiLoaded: {
type: Boolean, type: Boolean,
value: false, value: false,
}, },
_apiLoaded: { _isLoading: {
type: Boolean, type: Boolean,
value: false, computed: '_computeIsLoading(isLoadingData, _apiLoaded)',
}, },
};
}
_isLoading: { _computeIsSingleLineChart(historyData, noSingle) {
type: Boolean, return !noSingle && historyData && historyData.line.length === 1;
computed: '_computeIsLoading(isLoadingData, _apiLoaded)', }
},
},
_computeIsSingleLineChart: function (historyData) { _googleApiLoaded() {
return historyData && historyData.line.length === 1;
},
_googleApiLoaded: function () {
window.google.load('visualization', '1', { window.google.load('visualization', '1', {
packages: ['timeline', 'corechart'], packages: ['timeline', 'corechart'],
callback: function () { callback: function () {
this._apiLoaded = true; this._apiLoaded = true;
}.bind(this), }.bind(this),
}); });
}, }
_computeIsLoading: function (_isLoadingData, _apiLoaded) { _computeIsLoading(_isLoadingData, _apiLoaded) {
return _isLoadingData || !_apiLoaded; return _isLoadingData || !_apiLoaded;
}, }
_computeIsEmpty: function (historyData) { _computeIsEmpty(historyData) {
return (historyData && return (historyData &&
historyData.timeline.length === 0 && historyData.timeline.length === 0 &&
historyData.line.length === 0); historyData.line.length === 0);
}, }
_computeEndTime: function (endTime, upToNow) { _computeEndTime(endTime, upToNow) {
// We don't really care about the value of historyData, but if it change we want to update
// endTime.
return upToNow ? new Date() : endTime; return upToNow ? new Date() : endTime;
}, }
}); }
customElements.define(StateHistoryCharts.is, StateHistoryCharts);
</script> </script>

View File

@ -1,35 +1,43 @@
<link rel="import" href="../../bower_components/polymer/polymer.html"> <link rel="import" href="../../bower_components/polymer/polymer-element.html">
<script> <script>
(function () { {
var RECENT_THRESHOLD = 60000; // 1 minute const RECENT_THRESHOLD = 60000; // 1 minute
var RECENT_CACHE = {}; const RECENT_CACHE = {};
const DOMAINS_USE_LAST_UPDATED = ['thermostat', 'climate'];
const LINE_ATTRIBUTES_TO_KEEP = ['temperature', 'current_temperature', 'target_temp_low', 'target_temp_high'];
window.stateHistoryCache = window.stateHistoryCache || {};
function computeHistory(stateHistory) { function computeHistory(stateHistory) {
var lineChartDevices = {}; const lineChartDevices = {};
var timelineDevices = []; const timelineDevices = [];
var unitStates;
if (!stateHistory) { if (!stateHistory) {
return { line: [], timeline: [] }; return { line: [], timeline: [] };
} }
stateHistory.forEach(function (stateInfo) { stateHistory.forEach((stateInfo) => {
var stateWithUnit; if (stateInfo.length === 0) {
var unit;
if (stateInfo.size === 0) {
return; return;
} }
stateWithUnit = stateInfo.find( const stateWithUnit = stateInfo.find(
function (state) { return 'unit_of_measurement' in state.attributes; }); state => 'unit_of_measurement' in state.attributes);
unit = stateWithUnit ? const unit = stateWithUnit ?
stateWithUnit.attributes.unit_of_measurement : false; stateWithUnit.attributes.unit_of_measurement : false;
if (!unit) { if (!unit) {
timelineDevices.push(stateInfo); timelineDevices.push({
name: window.hassUtil.computeStateName(stateInfo[0]),
entity_id: stateInfo[0].entity_id,
data: stateInfo
.map(state => ({ state: state.state, last_changed: state.last_changed }))
.filter((element, index, arr) => {
if (index === 0) return true;
return element.state !== arr[index - 1].state;
})
});
} else if (unit in lineChartDevices) { } else if (unit in lineChartDevices) {
lineChartDevices[unit].push(stateInfo); lineChartDevices[unit].push(stateInfo);
} else { } else {
@ -37,135 +45,295 @@
} }
}); });
unitStates = Object.keys(lineChartDevices).map( const unitStates = Object.keys(lineChartDevices).map(
function (unit) { unit => ({
return { unit: unit, data: lineChartDevices[unit] }; unit: unit,
}); data: lineChartDevices[unit].map((states) => {
const last = states[states.length - 1];
const domain = window.hassUtil.computeDomain(last);
return {
domain: domain,
name: window.hassUtil.computeStateName(last),
entity_id: last.entity_id,
states: states.map((state) => {
const result = {
state: state.state,
last_changed: state.last_changed,
};
if (DOMAINS_USE_LAST_UPDATED.indexOf(domain) !== -1) {
result.last_changed = state.last_updated;
}
LINE_ATTRIBUTES_TO_KEEP.forEach((attr) => {
if (attr in state.attributes) {
result.attributes = result.attributes || {};
result.attributes[attr] = state.attributes[attr];
}
});
return result;
})
};
}),
}));
return { line: unitStates, timeline: timelineDevices }; return {
line: unitStates, timeline: timelineDevices };
} }
Polymer({ class HaStateHistoryData extends Polymer.Element {
is: 'ha-state-history-data', static get is() { return 'ha-state-history-data'; }
static get properties() {
return {
hass: {
type: Object,
observer: 'hassChanged',
},
properties: { filterType: String,
hass: {
type: Object,
observer: 'hassChanged',
},
filterType: { cacheConfig: Object,
type: String,
},
startTime: { startTime: Date,
type: Date, endTime: Date,
value: null,
},
endTime: { entityId: String,
type: Date,
value: null,
},
entityId: { isLoading: {
type: String, type: Boolean,
value: null, value: true,
}, readOnly: true,
notify: true,
},
isLoading: { data: {
type: Boolean, type: Object,
value: true, value: null,
readOnly: true, readOnly: true,
notify: true, notify: true,
}, },
};
}
data: { static get observers() {
type: Object, return [
value: null, 'filterChanged(filterType, entityId, startTime, endTime, cacheConfig)',
readOnly: true, ];
notify: true, }
},
},
observers: [ disconnectedCallback() {
'filterChanged(filterType, entityId, startTime, endTime)', if (this._refreshTimeoutId) {
], window.clearInterval(this._refreshTimeoutId);
this._refreshTimeoutId = null;
hassChanged: function (newHass, oldHass) {
if (!oldHass) {
this.filterChanged(this.filterType, this.entityId, this.startTime, this.endTime);
} }
}, super.disconnectedCallback();
}
filterChanged: function (filterType, entityId, startTime, endTime) { hassChanged(newHass, oldHass) {
if (!oldHass && !this._madeFirstCall) {
this.filterChanged(this.filterType, this.entityId, this.startTime, this.endTime,
this.cacheConfig);
}
}
filterChanged(filterType, entityId, startTime, endTime, cacheConfig) {
if (!this.hass) return; if (!this.hass) return;
this._madeFirstCall = true;
var data; let data;
if (filterType === 'date') { if (filterType === 'date') {
if (startTime === null || endTime === null) return; if (!startTime || !endTime) return;
data = this.getDate(startTime, endTime); data = this.getDate(startTime, endTime);
} else if (filterType === 'recent-entity') { } else if (filterType === 'recent-entity') {
if (entityId === null) return; if (!entityId) return;
data = this.getRecent(entityId); if (cacheConfig) {
data = this.getRecentWithCacheRefresh(entityId, cacheConfig);
} else {
data = this.getRecent(entityId, startTime, endTime);
}
} else { } else {
return; return;
} }
this._setIsLoading(true); this._setIsLoading(true);
data.then(function (stateHistory) { data.then((stateHistory) => {
this._setData(stateHistory); this._setData(stateHistory);
this._setIsLoading(false); this._setIsLoading(false);
}.bind(this)); });
}, }
getRecent: function (entityId) { getEmptyCache() {
var cache = RECENT_CACHE[entityId]; return {
prom: Promise.resolve({ line: [], timeline: [] }),
data: { line: [], timeline: [] },
};
}
getRecentWithCacheRefresh(entityId, cacheConfig) {
if (this._refreshTimeoutId) {
window.clearInterval(this._refreshTimeoutId);
}
if (cacheConfig.refresh) {
this._refreshTimeoutId = window.setInterval(() => {
this.getRecentWithCache(entityId, cacheConfig)
.then((stateHistory) => {
this._setData(Object.assign({}, stateHistory));
});
}, cacheConfig.refresh * 1000);
}
return this.getRecentWithCache(entityId, cacheConfig);
}
mergeLine(historyLines, cacheLines) {
historyLines.forEach((line) => {
const unit = line.unit;
const oldLine = cacheLines.find(cacheLine => cacheLine.unit === unit);
if (oldLine) {
line.data.forEach((entity) => {
const oldEntity =
oldLine.data.find(cacheEntity => entity.entity_id === cacheEntity.entity_id);
if (oldEntity) {
oldEntity.states = oldEntity.state.concat(entity.states);
} else {
oldLine.data.push(entity);
}
});
} else {
cacheLines.push(line);
}
});
}
mergeTimeline(historyTimelines, cacheTimelines) {
historyTimelines.forEach((timeline) => {
const oldTimeline =
cacheTimelines.find(cacheTimeline => cacheTimeline.entity_id === timeline.entity_id);
if (oldTimeline) {
oldTimeline.data = oldTimeline.data.concat(timeline.data);
} else {
cacheTimelines.push(timeline);
}
});
}
pruneArray(originalStartTime, arr) {
if (arr.length === 0) return arr;
const changedAfterStartTime = arr.findIndex((state) => {
const lastChanged = new Date(state.last_changed);
return lastChanged > originalStartTime;
});
if (changedAfterStartTime === 0) {
// If all changes happened after originalStartTime then we are done.
return arr;
}
// If all changes happened at or before originalStartTime. Use last index.
const updateIndex = changedAfterStartTime === -1 ? arr.length - 1 : changedAfterStartTime - 1;
arr[updateIndex].last_changed = originalStartTime;
return arr.slice(updateIndex);
}
pruneStartTime(originalStartTime, cacheData) {
cacheData.line.forEach((line) => {
line.data.forEach((entity) => {
entity.states = this.pruneArray(originalStartTime, entity.states);
});
});
cacheData.timeline.forEach((timeline) => {
timeline.data = this.pruneArray(originalStartTime, timeline.data);
});
}
getRecentWithCache(entityId, cacheConfig) {
const cacheKey = cacheConfig.cacheKey;
const endTime = new Date();
const originalStartTime = new Date(endTime);
originalStartTime.setHours(originalStartTime.getHours() - cacheConfig.hoursToShow);
let startTime = originalStartTime;
let appendingToCache = false;
let cache = window.stateHistoryCache[cacheKey];
if (cache && startTime >= cache.startTime && startTime <= cache.endTime) {
startTime = cache.endTime;
appendingToCache = true;
if (endTime <= cache.endTime) {
return cache.prom;
}
} else {
cache = window.stateHistoryCache[cacheKey] = this.getEmptyCache();
}
// Use Promise.all in order to make sure the old and the new fetches have both completed.
const prom = Promise.all([cache.prom,
this.fetchRecent(entityId, startTime, endTime, appendingToCache)])
// Use only data from the new fetch. Old fetch is already stored in cache.data
.then(oldAndNew => oldAndNew[1])
// Convert data into format state-history-chart-* understands.
.then(stateHistory => computeHistory(stateHistory))
// Merge old and new.
.then((stateHistory) => {
this.mergeLine(stateHistory.line, cache.data.line);
this.mergeTimeline(stateHistory.timeline, cache.data.timeline);
if (appendingToCache) {
this.pruneStartTime(originalStartTime, cache.data);
}
return cache.data;
})
.catch(() => {
window.stateHistoryCache[cacheKey] = undefined;
});
cache.prom = prom;
cache.startTime = originalStartTime;
cache.endTime = endTime;
return prom;
}
getRecent(entityId, startTime, endTime) {
const cacheKey = entityId;
const cache = RECENT_CACHE[cacheKey];
if (cache && Date.now() - cache.created < RECENT_THRESHOLD) { if (cache && Date.now() - cache.created < RECENT_THRESHOLD) {
return cache.data; return cache.data;
} }
var url = 'history/period'; const prom = this.fetchRecent(entityId, startTime, endTime).then(
stateHistory => computeHistory(stateHistory),
if (entityId) { () => {
url += '?filter_entity_id=' + entityId;
}
var prom = this.hass.callApi('GET', url).then(
function (stateHistory) {
return computeHistory(stateHistory);
},
function () {
RECENT_CACHE[entityId] = false; RECENT_CACHE[entityId] = false;
return null; return null;
} });
);
RECENT_CACHE[entityId] = { RECENT_CACHE[cacheKey] = {
created: Date.now(), created: Date.now(),
data: prom, data: prom,
}; };
return prom;
}
fetchRecent(entityId, startTime, endTime, skipInitialState = false) {
let url = 'history/period';
if (startTime) {
url += '/' + startTime.toISOString();
}
url += '?filter_entity_id=' + entityId;
if (endTime) {
url += '&end_time=' + endTime.toISOString();
}
if (skipInitialState) {
url += '&skip_initial_state';
}
return this.hass.callApi('GET', url);
}
getDate(startTime, endTime) {
const filter = startTime.toISOString() + '?end_time=' + endTime.toISOString();
const prom = this.hass.callApi('GET', 'history/period/' + filter).then(
stateHistory => computeHistory(stateHistory),
() => null);
return prom; return prom;
}, }
}
getDate: function (startTime, endTime) { customElements.define(HaStateHistoryData.is, HaStateHistoryData);
var filter = startTime.toISOString() + '?end_time=' + endTime.toISOString(); }
var prom = this.hass.callApi('GET', 'history/period/' + filter).then(
function (stateHistory) {
return computeHistory(stateHistory);
},
function () {
return null;
}
);
return prom;
},
});
}());
</script> </script>

View File

@ -1,4 +1,4 @@
<link rel="import" href="../../bower_components/polymer/polymer.html"> <link rel="import" href="../../bower_components/polymer/polymer-element.html">
<link rel="import" href="../../bower_components/paper-dialog/paper-dialog.html"> <link rel="import" href="../../bower_components/paper-dialog/paper-dialog.html">
<link rel="import" href="../../bower_components/paper-dialog-scrollable/paper-dialog-scrollable.html"> <link rel="import" href="../../bower_components/paper-dialog-scrollable/paper-dialog-scrollable.html">
@ -9,6 +9,7 @@
<link rel="import" href="../components/state-history-charts.html"> <link rel="import" href="../components/state-history-charts.html">
<link rel="import" href="../more-infos/more-info-content.html"> <link rel="import" href="../more-infos/more-info-content.html">
<link rel="import" href="../data/ha-state-history-data.html"> <link rel="import" href="../data/ha-state-history-data.html">
<link rel='import' href='../util/hass-mixins.html'>
<dom-module id="more-info-dialog"> <dom-module id="more-info-dialog">
<template> <template>
@ -23,6 +24,14 @@
width: auto; width: auto;
} }
paper-dialog[data-domain=history_graph] {
width: 90%;
}
paper-dialog[data-domain=history_graph] h2 {
display: none;
}
state-history-charts { state-history-charts {
position: relative; position: relative;
z-index: 1; z-index: 1;
@ -48,6 +57,13 @@
border-bottom-left-radius: 0px; border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px; border-bottom-right-radius: 0px;
} }
paper-dialog[data-domain=history_graph] {
width: 100%;
}
}
dom-if {
display: none;
} }
</style> </style>
@ -63,10 +79,11 @@
<div> <div>
<ha-state-history-data <ha-state-history-data
hass='[[hass]]' hass='[[hass]]'
filter-type='[[_filterType]]' filter-type='recent-entity'
entity-id='[[stateObj.entity_id]]' entity-id='[[stateObj.entity_id]]'
data='{{stateHistory}}' data='{{stateHistory}}'
is-loading='{{stateHistoryLoading}}' is-loading='{{stateHistoryLoading}}'
cache-config='[[computeCacheConfig(stateObj)]]'
></ha-state-history-data> ></ha-state-history-data>
<state-history-charts <state-history-charts
history-data="[[stateHistory]]" history-data="[[stateHistory]]"
@ -84,72 +101,71 @@
</dom-module> </dom-module>
<script> <script>
Polymer({ class MoreInfoDialog extends window.hassMixins.EventsMixin(Polymer.Element) {
is: 'more-info-dialog', static get is() { return 'more-info-dialog'; }
static get properties() {
return {
hass: Object,
properties: { stateObj: {
hass: { type: Object,
type: Object, computed: 'computeStateObj(hass)',
}, observer: 'stateObjChanged',
},
stateObj: { stateHistory: Object,
type: Object,
computed: 'computeStateObj(hass)',
observer: 'stateObjChanged',
},
stateHistory: { stateHistoryLoading: Boolean,
type: Object,
},
stateHistoryLoading: { isLoadingHistoryData: {
type: Boolean, type: Boolean,
}, computed: 'computeIsLoadingHistoryData(delayedDialogOpen, stateHistoryLoading)',
},
isLoadingHistoryData: { hasHistoryComponent: {
type: Boolean, type: Boolean,
computed: 'computeIsLoadingHistoryData(delayedDialogOpen, stateHistoryLoading)', computed: 'computeHasHistoryComponent(hass)',
}, },
hasHistoryComponent: { showHistoryComponent: {
type: Boolean, type: Boolean,
computed: 'computeHasHistoryComponent(hass)', value: false,
}, computed: 'computeShowHistoryComponent(hasHistoryComponent, stateObj)',
},
showHistoryComponent: { dialogOpen: {
type: Boolean, type: Boolean,
value: false, value: false,
computed: 'computeShowHistoryComponent(hasHistoryComponent, stateObj)', observer: 'dialogOpenChanged',
}, },
dialogOpen: { delayedDialogOpen: {
type: Boolean, type: Boolean,
value: false, value: false,
observer: 'dialogOpenChanged', },
}, };
}
delayedDialogOpen: { connectedCallback() {
type: Boolean, super.connectedCallback();
value: false,
},
_filterType: {
type: String,
value: 'recent-entity',
},
},
ready: function () {
this.$.scrollable.dialogElement = this.$.dialog; this.$.scrollable.dialogElement = this.$.dialog;
}, }
computeDomain: function (stateObj) { computeDomain(stateObj) {
return stateObj ? window.hassUtil.computeDomain(stateObj) : ''; return stateObj ? window.hassUtil.computeDomain(stateObj) : '';
}, }
computeStateObj: function (hass) { computeStateObj(hass) {
return hass.states[hass.moreInfoEntityId] || null; return hass.states[hass.moreInfoEntityId] || null;
}, }
computeCacheConfig(stateObj) {
return {
refresh: 60,
cacheKey: 'more_info.' + stateObj.entity_id,
hoursToShow: 24,
};
}
/** /**
* We depend on a delayed dialogOpen value to tell the chart component * We depend on a delayed dialogOpen value to tell the chart component
@ -157,40 +173,41 @@ Polymer({
* before the dialog is attached to the screen and is unable to determine * before the dialog is attached to the screen and is unable to determine
* graph size resulting in scroll bars. * graph size resulting in scroll bars.
*/ */
computeIsLoadingHistoryData: function (delayedDialogOpen, stateHistoryLoading) { computeIsLoadingHistoryData(delayedDialogOpen, stateHistoryLoading) {
return !delayedDialogOpen || stateHistoryLoading; return !delayedDialogOpen || stateHistoryLoading;
}, }
computeHasHistoryComponent: function (hass) { computeHasHistoryComponent(hass) {
return window.hassUtil.isComponentLoaded(hass, 'history'); return window.hassUtil.isComponentLoaded(hass, 'history');
}, }
computeShowHistoryComponent: function (hasHistoryComponent, stateObj) { computeShowHistoryComponent(hasHistoryComponent, stateObj) {
return this.hasHistoryComponent && stateObj && return this.hasHistoryComponent && stateObj &&
window.hassUtil.DOMAINS_WITH_NO_HISTORY.indexOf( window.hassUtil.DOMAINS_WITH_NO_HISTORY.indexOf(
window.hassUtil.computeDomain(stateObj)) === -1; window.hassUtil.computeDomain(stateObj)) === -1;
}, }
stateObjChanged: function (newVal) { stateObjChanged(newVal) {
if (!newVal) { if (!newVal) {
this.dialogOpen = false; this.dialogOpen = false;
return; return;
} }
this.async(function () { window.setTimeout(() => {
// allow dialog to render content before showing it so it is // allow dialog to render content before showing it so it is
// positioned correctly. // positioned correctly.
this.dialogOpen = true; this.dialogOpen = true;
}.bind(this), 10); }, 10);
}, }
dialogOpenChanged: function (newVal) { dialogOpenChanged(newVal) {
if (newVal) { if (newVal) {
this.async(function () { this.delayedDialogOpen = true; }.bind(this), 100); window.setTimeout(() => { this.delayedDialogOpen = true; }, 100);
} else if (!newVal && this.stateObj) { } else if (!newVal && this.stateObj) {
this.fire('hass-more-info', { entityId: null }); this.fire('hass-more-info', { entityId: null });
this.delayedDialogOpen = false; this.delayedDialogOpen = false;
} }
}, }
}); }
customElements.define(MoreInfoDialog.is, MoreInfoDialog);
</script> </script>

View File

@ -8,6 +8,7 @@
<link rel='import' href='more-info-cover.html'> <link rel='import' href='more-info-cover.html'>
<link rel='import' href='more-info-default.html'> <link rel='import' href='more-info-default.html'>
<link rel='import' href='more-info-fan.html'> <link rel='import' href='more-info-fan.html'>
<link rel='import' href='more-info-history_graph.html'>
<link rel='import' href='more-info-group.html'> <link rel='import' href='more-info-group.html'>
<link rel='import' href='more-info-light.html'> <link rel='import' href='more-info-light.html'>
<link rel='import' href='more-info-lock.html'> <link rel='import' href='more-info-lock.html'>

View File

@ -0,0 +1,35 @@
<link rel="import" href="../../bower_components/polymer/polymer-element.html">
<link rel="import" href="../cards/ha-history_graph-card.html">
<link rel="import" href="../components/ha-attributes.html">
<dom-module id="more-info-history_graph">
<template>
<style>
:host {
display: block;
margin-bottom: 6px;
}
</style>
<ha-history_graph-card
hass='[[hass]]'
state-obj='[[stateObj]]'
in-dialog
></ha-graph-card>
<ha-attributes state-obj="[[stateObj]]"></ha-attributes>
</template>
</dom-module>
<script>
class MoreInfoHistoryGraph extends Polymer.Element {
static get is() { return 'more-info-history_graph'; }
static get properties() {
return {
hass: Object,
stateObj: Object,
};
}
}
customElements.define(MoreInfoHistoryGraph.is, MoreInfoHistoryGraph);
</script>

View File

@ -25,11 +25,11 @@ window.hassUtil.DOMAINS_WITH_CARD = [
window.hassUtil.DOMAINS_WITH_MORE_INFO = [ window.hassUtil.DOMAINS_WITH_MORE_INFO = [
'alarm_control_panel', 'automation', 'camera', 'climate', 'configurator', 'alarm_control_panel', 'automation', 'camera', 'climate', 'configurator',
'cover', 'fan', 'group', 'light', 'lock', 'media_player', 'script', 'cover', 'fan', 'group', 'history_graph', 'light', 'lock', 'media_player', 'script',
'sun', 'updater', 'vacuum', 'sun', 'updater', 'vacuum',
]; ];
window.hassUtil.DOMAINS_WITH_NO_HISTORY = ['camera', 'configurator', 'scene']; window.hassUtil.DOMAINS_WITH_NO_HISTORY = ['camera', 'configurator', 'history_graph', 'scene'];
window.hassUtil.HIDE_MORE_INFO = [ window.hassUtil.HIDE_MORE_INFO = [
'input_select', 'scene', 'script', 'input_number', 'input_text' 'input_select', 'scene', 'script', 'input_number', 'input_text'
@ -274,6 +274,9 @@ window.hassUtil.domainIcon = function (domain, state) {
case 'fan': case 'fan':
return 'mdi:fan'; return 'mdi:fan';
case 'history_graph':
return 'mdi:chart-line';
case 'group': case 'group':
return 'mdi:google-circles-communities'; return 'mdi:google-circles-communities';