mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-24 09:46:36 +00:00
Augment history panel with Long Term Statistics (#18213)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
parent
7727f34e8f
commit
b6a7581eca
@ -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);
|
||||
|
@ -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,
|
||||
}
|
||||
),
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -44,6 +44,7 @@ export interface LineChartEntity {
|
||||
name: string;
|
||||
entity_id: string;
|
||||
states: LineChartState[];
|
||||
statistics?: LineChartState[];
|
||||
}
|
||||
|
||||
export interface LineChartUnit {
|
||||
|
@ -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;
|
||||
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user