Augment history panel with Long Term Statistics (#18213)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
karwosts 2023-11-28 13:14:05 -08:00 committed by GitHub
parent 7727f34e8f
commit b6a7581eca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 294 additions and 8 deletions

View File

@ -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);

View File

@ -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,
}
),
],
}
: {}),
};
}
}

View File

@ -44,6 +44,7 @@ export interface LineChartEntity {
name: string;
entity_id: string;
states: LineChartState[];
statistics?: LineChartState[];
}
export interface LineChartUnit {

View File

@ -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}
></ha-date-range-picker>
<ha-target-picker
@ -194,7 +204,7 @@ class HaPanelHistory extends SubscribeMixin(LitElement) {
: html`
<state-history-charts
.hass=${this.hass}
.historyData=${this._stateHistory}
.historyData=${this._mungedStateHistory}
.startTime=${this._startDate}
.endTime=${this._endDate}
>
@ -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<void> {
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;

View File

@ -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"