From b6a7581eca0ba65db4d862c066d28ee49c5a038d Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Tue, 28 Nov 2023 13:14:05 -0800 Subject: [PATCH] Augment history panel with Long Term Statistics (#18213) Co-authored-by: Bram Kragten --- .../chart/state-history-chart-line.ts | 62 +++++++- src/components/ha-date-range-picker.ts | 94 ++++++++++++ src/data/history.ts | 1 + src/panels/history/ha-panel-history.ts | 137 +++++++++++++++++- src/translations/en.json | 8 +- 5 files changed, 294 insertions(+), 8 deletions(-) diff --git a/src/components/chart/state-history-chart-line.ts b/src/components/chart/state-history-chart-line.ts index bdde57a7ad0c..e712762470cd 100644 --- a/src/components/chart/state-history-chart-line.ts +++ b/src/components/chart/state-history-chart-line.ts @@ -51,6 +51,8 @@ export class StateHistoryChartLine extends LitElement { @state() private _entityIds: string[] = []; + private _datasetToDataIndex: number[] = []; + @state() private _chartOptions?: ChartOptions; @state() private _yWidth = 0; @@ -81,6 +83,7 @@ export class StateHistoryChartLine extends LitElement { changedProps.has("showNames") || changedProps.has("startTime") || changedProps.has("endTime") || + changedProps.has("unit") || changedProps.has("logarithmicScale") ) { this._chartOptions = { @@ -141,15 +144,32 @@ export class StateHistoryChartLine extends LitElement { plugins: { tooltip: { callbacks: { - label: (context) => - `${context.dataset.label}: ${formatNumber( + label: (context) => { + let label = `${context.dataset.label}: ${formatNumber( context.parsed.y, this.hass.locale, getNumberFormatOptions( undefined, this.hass.entities[this._entityIds[context.datasetIndex]] ) - )} ${this.unit}`, + )} ${this.unit}`; + const dataIndex = + this._datasetToDataIndex[context.datasetIndex]; + const data = this.data[dataIndex]; + if (data.statistics && data.statistics.length > 0) { + const source = + data.states.length === 0 || + context.parsed.x < data.states[0].last_changed + ? `\n${this.hass.localize( + "ui.components.history_charts.source_stats" + )}` + : `\n${this.hass.localize( + "ui.components.history_charts.source_history" + )}`; + label += source; + } + return label; + }, }, }, filler: { @@ -171,6 +191,19 @@ export class StateHistoryChartLine extends LitElement { hitRadius: 50, }, }, + segment: { + borderColor: (context) => { + // render stat data with a slightly transparent line + const dataIndex = this._datasetToDataIndex[context.datasetIndex]; + const data = this.data[dataIndex]; + return data.statistics && + data.statistics.length > 0 && + (data.states.length === 0 || + context.p0.parsed.x < data.states[0].last_changed) + ? this._chartData!.datasets[dataIndex].borderColor + "7F" + : undefined; + }, + }, // @ts-expect-error locale: numberFormatToLocale(this.hass.locale), onClick: (e: any) => { @@ -216,6 +249,7 @@ export class StateHistoryChartLine extends LitElement { const entityStates = this.data; const datasets: ChartDataset<"line">[] = []; const entityIds: string[] = []; + const datasetToDataIndex: number[] = []; if (entityStates.length === 0) { return; } @@ -223,7 +257,7 @@ export class StateHistoryChartLine extends LitElement { this._chartTime = new Date(); const endTime = this.endTime; const names = this.names || {}; - entityStates.forEach((states) => { + entityStates.forEach((states, dataIdx) => { const domain = states.domain; const name = names[states.entity_id] || states.name; // array containing [value1, value2, etc] @@ -268,6 +302,7 @@ export class StateHistoryChartLine extends LitElement { data: [], }); entityIds.push(states.entity_id); + datasetToDataIndex.push(dataIdx); }; if ( @@ -474,7 +509,7 @@ export class StateHistoryChartLine extends LitElement { // Process chart data. // When state is `unknown`, calculate the value and break the line. - states.states.forEach((entityState) => { + const processData = (entityState: LineChartState) => { const value = safeParseFloat(entityState.state); const date = new Date(entityState.last_changed); if (value !== null && lastNullDate) { @@ -503,6 +538,22 @@ export class StateHistoryChartLine extends LitElement { ) { lastNullDate = date; } + }; + + if (states.statistics) { + const stopTime = + !states.states || states.states.length === 0 + ? 0 + : states.states[0].last_changed; + for (let i = 0; i < states.statistics.length; i++) { + if (stopTime && states.statistics[i].last_changed >= stopTime) { + break; + } + processData(states.statistics[i]); + } + } + states.states.forEach((entityState) => { + processData(entityState); }); if (lastNullDate !== null) { pushData(lastNullDate, [null]); @@ -520,6 +571,7 @@ export class StateHistoryChartLine extends LitElement { datasets, }; this._entityIds = entityIds; + this._datasetToDataIndex = datasetToDataIndex; } } customElements.define("state-history-chart-line", StateHistoryChartLine); diff --git a/src/components/ha-date-range-picker.ts b/src/components/ha-date-range-picker.ts index ed5403872f07..29b43c697918 100644 --- a/src/components/ha-date-range-picker.ts +++ b/src/components/ha-date-range-picker.ts @@ -5,10 +5,16 @@ import "@material/mwc-list/mwc-list-item"; import { mdiCalendar } from "@mdi/js"; import { addDays, + addMonths, + addYears, endOfDay, endOfWeek, + endOfMonth, + endOfYear, startOfDay, startOfWeek, + startOfMonth, + startOfYear, } from "date-fns"; import { css, @@ -60,6 +66,8 @@ export class HaDateRangePicker extends LitElement { @property({ type: Boolean }) private minimal = false; + @property({ type: Boolean }) public extendedPresets = false; + @property() public openingDirection?: "right" | "left" | "center" | "inline"; @state() private _calcedOpeningDirection?: @@ -132,6 +140,92 @@ export class HaDateRangePicker extends LitElement { [this.hass.localize( "ui.components.date-range-picker.ranges.last_week" )]: [addDays(weekStart, -7), addDays(weekEnd, -7)], + ...(this.extendedPresets + ? { + [this.hass.localize( + "ui.components.date-range-picker.ranges.this_month" + )]: [ + calcDate( + today, + startOfMonth, + this.hass.locale, + this.hass.config, + { + weekStartsOn, + } + ), + calcDate( + today, + endOfMonth, + this.hass.locale, + this.hass.config, + { + weekStartsOn, + } + ), + ], + [this.hass.localize( + "ui.components.date-range-picker.ranges.last_month" + )]: [ + calcDate( + addMonths(today, -1), + startOfMonth, + this.hass.locale, + this.hass.config, + { + weekStartsOn, + } + ), + calcDate( + addMonths(today, -1), + endOfMonth, + this.hass.locale, + this.hass.config, + { + weekStartsOn, + } + ), + ], + [this.hass.localize( + "ui.components.date-range-picker.ranges.this_year" + )]: [ + calcDate( + today, + startOfYear, + this.hass.locale, + this.hass.config, + { + weekStartsOn, + } + ), + calcDate(today, endOfYear, this.hass.locale, this.hass.config, { + weekStartsOn, + }), + ], + [this.hass.localize( + "ui.components.date-range-picker.ranges.last_year" + )]: [ + calcDate( + addYears(today, -1), + startOfYear, + this.hass.locale, + this.hass.config, + { + weekStartsOn, + } + ), + calcDate( + addYears(today, -1), + endOfYear, + this.hass.locale, + this.hass.config, + { + weekStartsOn, + } + ), + ], + } + : {}), }; } } diff --git a/src/data/history.ts b/src/data/history.ts index 708c8d97c8de..2cbbcaf24c8b 100644 --- a/src/data/history.ts +++ b/src/data/history.ts @@ -44,6 +44,7 @@ export interface LineChartEntity { name: string; entity_id: string; states: LineChartState[]; + statistics?: LineChartState[]; } export interface LineChartUnit { diff --git a/src/panels/history/ha-panel-history.ts b/src/panels/history/ha-panel-history.ts index 17272454d6d0..f443008388fa 100644 --- a/src/panels/history/ha-panel-history.ts +++ b/src/panels/history/ha-panel-history.ts @@ -42,7 +42,12 @@ import { HistoryResult, computeHistory, subscribeHistory, + HistoryStates, + EntityHistoryState, + LineChartUnit, + LineChartEntity, } from "../../data/history"; +import { fetchStatistics, Statistics } from "../../data/recorder"; import { getSensorNumericDeviceClasses } from "../../data/sensor"; import { SubscribeMixin } from "../../mixins/subscribe-mixin"; import { haStyle } from "../../resources/styles"; @@ -70,6 +75,10 @@ class HaPanelHistory extends SubscribeMixin(LitElement) { @state() private _stateHistory?: HistoryResult; + private _mungedStateHistory?: HistoryResult; + + @state() private _statisticsHistory?: HistoryResult; + @state() private _deviceEntityLookup?: DeviceEntityLookup; @state() private _areaEntityLookup?: AreaEntityLookup; @@ -170,6 +179,7 @@ class HaPanelHistory extends SubscribeMixin(LitElement) { ?disabled=${this._isLoading} .startDate=${this._startDate} .endDate=${this._endDate} + extendedPresets @change=${this._dateRangeChanged} > @@ -205,9 +215,72 @@ class HaPanelHistory extends SubscribeMixin(LitElement) { `; } + private mergeHistoryResults( + ltsResult: HistoryResult, + historyResult: HistoryResult + ): HistoryResult { + const result: HistoryResult = { ...historyResult, line: [] }; + + const units = new Set( + historyResult.line + .map((i) => i.unit) + .concat(ltsResult.line.map((i) => i.unit)) + ); + units.forEach((unit) => { + const historyItem = historyResult.line.find((i) => i.unit === unit); + const ltsItem = ltsResult.line.find((i) => i.unit === unit); + if (historyItem && ltsItem) { + const newLineItem: LineChartUnit = { ...historyItem, data: [] }; + const entities = new Set( + historyItem.data + .map((d) => d.entity_id) + .concat(ltsItem.data.map((d) => d.entity_id)) + ); + entities.forEach((entity) => { + const historyDataItem = historyItem.data.find( + (d) => d.entity_id === entity + ); + const ltsDataItem = ltsItem.data.find((d) => d.entity_id === entity); + if (historyDataItem && ltsDataItem) { + const newDataItem: LineChartEntity = { + ...historyDataItem, + statistics: ltsDataItem.statistics, + }; + newLineItem.data.push(newDataItem); + } else { + newLineItem.data.push(historyDataItem || ltsDataItem!); + } + }); + result.line.push(newLineItem); + } else { + // Only one result has data for this item, so just push it directly instead of merging. + result.line.push(historyItem || ltsItem!); + } + }); + return result; + } + public willUpdate(changedProps: PropertyValues) { super.willUpdate(changedProps); + if ( + changedProps.has("_stateHistory") || + changedProps.has("_statisticsHistory") || + changedProps.has("_startDate") || + changedProps.has("_endDate") || + changedProps.has("_targetPickerValue") + ) { + if (this._statisticsHistory && this._stateHistory) { + this._mungedStateHistory = this.mergeHistoryResults( + this._statisticsHistory, + this._stateHistory + ); + } else { + this._mungedStateHistory = + this._stateHistory || this._statisticsHistory; + } + } + if (this.hasUpdated) { return; } @@ -265,6 +338,7 @@ class HaPanelHistory extends SubscribeMixin(LitElement) { changedProps.has("_areaDeviceLookup")))) ) { this._getHistory(); + this._getStats(); } if (!changedProps.has("hass") && !changedProps.has("_entities")) { @@ -282,6 +356,67 @@ class HaPanelHistory extends SubscribeMixin(LitElement) { this._updatePath(); } + private async _getStats() { + const entityIds = this._getEntityIds(); + if (!entityIds) { + return; + } + this._getStatistics(entityIds); + } + + private async _getStatistics(statisticIds: string[]): Promise { + try { + const statistics = await fetchStatistics( + this.hass!, + this._startDate, + this._endDate, + statisticIds, + "hour", + undefined, + ["mean", "state"] + ); + + // Maintain the statistic id ordering + const orderedStatistics: Statistics = {}; + statisticIds.forEach((id) => { + if (id in statistics) { + orderedStatistics[id] = statistics[id]; + } + }); + + // Convert statistics to HistoryResult format + const statsHistoryStates: HistoryStates = {}; + Object.entries(orderedStatistics).forEach(([key, value]) => { + const entityHistoryStates: EntityHistoryState[] = value.map((e) => ({ + s: e.mean != null ? e.mean.toString() : e.state!.toString(), + lc: e.start / 1000, + a: {}, + lu: e.start / 1000, + })); + statsHistoryStates[key] = entityHistoryStates; + }); + + const { numeric_device_classes: sensorNumericDeviceClasses } = + await getSensorNumericDeviceClasses(this.hass); + + this._statisticsHistory = computeHistory( + this.hass, + statsHistoryStates, + this.hass.localize, + sensorNumericDeviceClasses + ); + // remap states array to statistics array + (this._statisticsHistory?.line || []).forEach((item) => { + item.data.forEach((data) => { + data.statistics = data.states; + data.states = []; + }); + }); + } catch (err) { + this._statisticsHistory = undefined; + } + } + private async _getHistory() { if (!this._targetPickerValue) { return; diff --git a/src/translations/en.json b/src/translations/en.json index 268ad8ff84a6..201358d7c7bb 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -581,7 +581,9 @@ "last_week": "Last week", "this_quarter": "This quarter", "this_month": "This month", - "this_year": "This year" + "last_month": "Last month", + "this_year": "This year", + "last_year": "Last year" } }, "relative_time": { @@ -595,7 +597,9 @@ "loading_history": "Loading state history…", "no_history_found": "No state history found.", "error": "Unable to load history", - "duration": "Duration" + "duration": "Duration", + "source_history": "Source: History", + "source_stats": "Source: Long term statistics" }, "map": { "error": "Unable to load map"