819 lines
23 KiB
TypeScript
819 lines
23 KiB
TypeScript
import { mdiDownload, mdiFilterRemove } from "@mdi/js";
|
|
import { differenceInHours } from "date-fns/esm";
|
|
import {
|
|
HassServiceTarget,
|
|
UnsubscribeFunc,
|
|
} from "home-assistant-js-websocket/dist/types";
|
|
import { LitElement, PropertyValues, css, html } from "lit";
|
|
import { property, query, state } from "lit/decorators";
|
|
import memoizeOne from "memoize-one";
|
|
import { ensureArray } from "../../common/array/ensure-array";
|
|
import { storage } from "../../common/decorators/storage";
|
|
import { navigate } from "../../common/navigate";
|
|
import { constructUrlCurrentPath } from "../../common/url/construct-url";
|
|
import {
|
|
createSearchParam,
|
|
extractSearchParamsObject,
|
|
removeSearchParam,
|
|
} from "../../common/url/search-params";
|
|
import { MIN_TIME_BETWEEN_UPDATES } from "../../components/chart/ha-chart-base";
|
|
import "../../components/chart/state-history-charts";
|
|
import type { StateHistoryCharts } from "../../components/chart/state-history-charts";
|
|
import "../../components/ha-circular-progress";
|
|
import "../../components/ha-date-range-picker";
|
|
import "../../components/ha-icon-button";
|
|
import "../../components/ha-icon-button-arrow-prev";
|
|
import "../../components/ha-menu-button";
|
|
import "../../components/ha-target-picker";
|
|
import "../../components/ha-top-app-bar-fixed";
|
|
import {
|
|
AreaDeviceLookup,
|
|
AreaEntityLookup,
|
|
getAreaDeviceLookup,
|
|
getAreaEntityLookup,
|
|
} from "../../data/area_registry";
|
|
import {
|
|
DeviceEntityLookup,
|
|
getDeviceEntityLookup,
|
|
subscribeDeviceRegistry,
|
|
} from "../../data/device_registry";
|
|
import { subscribeEntityRegistry } from "../../data/entity_registry";
|
|
import {
|
|
HistoryResult,
|
|
computeHistory,
|
|
subscribeHistory,
|
|
HistoryStates,
|
|
EntityHistoryState,
|
|
LineChartUnit,
|
|
computeGroupKey,
|
|
LineChartState,
|
|
} 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";
|
|
import { HomeAssistant } from "../../types";
|
|
import { fileDownload } from "../../util/file_download";
|
|
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
|
|
import { computeDomain } from "../../common/entity/compute_domain";
|
|
|
|
class HaPanelHistory extends SubscribeMixin(LitElement) {
|
|
@property({ attribute: false }) hass!: HomeAssistant;
|
|
|
|
@property({ reflect: true, type: Boolean }) public narrow = false;
|
|
|
|
@property({ reflect: true, type: Boolean }) rtl = false;
|
|
|
|
@state() private _startDate: Date;
|
|
|
|
@state() private _endDate: Date;
|
|
|
|
@storage({
|
|
key: "historyPickedValue",
|
|
state: true,
|
|
subscribe: false,
|
|
})
|
|
private _targetPickerValue: HassServiceTarget = {};
|
|
|
|
@state() private _isLoading = false;
|
|
|
|
@state() private _stateHistory?: HistoryResult;
|
|
|
|
private _mungedStateHistory?: HistoryResult;
|
|
|
|
@state() private _statisticsHistory?: HistoryResult;
|
|
|
|
@state() private _deviceEntityLookup?: DeviceEntityLookup;
|
|
|
|
@state() private _areaEntityLookup?: AreaEntityLookup;
|
|
|
|
@state() private _areaDeviceLookup?: AreaDeviceLookup;
|
|
|
|
@state()
|
|
private _showBack?: boolean;
|
|
|
|
@query("state-history-charts")
|
|
private _stateHistoryCharts?: StateHistoryCharts;
|
|
|
|
private _subscribed?: Promise<UnsubscribeFunc>;
|
|
|
|
private _interval?: number;
|
|
|
|
public constructor() {
|
|
super();
|
|
|
|
const start = new Date();
|
|
start.setHours(start.getHours() - 1, 0, 0, 0);
|
|
this._startDate = start;
|
|
|
|
const end = new Date();
|
|
end.setHours(end.getHours() + 2, 0, 0, 0);
|
|
this._endDate = end;
|
|
}
|
|
|
|
public connectedCallback() {
|
|
super.connectedCallback();
|
|
if (this.hasUpdated) {
|
|
this._getHistory();
|
|
}
|
|
}
|
|
|
|
public disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
this._unsubscribeHistory();
|
|
}
|
|
|
|
public hassSubscribe(): UnsubscribeFunc[] {
|
|
return [
|
|
subscribeEntityRegistry(this.hass.connection!, (entities) => {
|
|
this._deviceEntityLookup = getDeviceEntityLookup(entities);
|
|
this._areaEntityLookup = getAreaEntityLookup(entities);
|
|
}),
|
|
subscribeDeviceRegistry(this.hass.connection!, (devices) => {
|
|
this._areaDeviceLookup = getAreaDeviceLookup(devices);
|
|
}),
|
|
];
|
|
}
|
|
|
|
private _goBack(): void {
|
|
history.back();
|
|
}
|
|
|
|
protected render() {
|
|
const entitiesSelected = this._getEntityIds().length > 0;
|
|
return html`
|
|
<ha-top-app-bar-fixed>
|
|
${this._showBack
|
|
? html`
|
|
<ha-icon-button-arrow-prev
|
|
slot="navigationIcon"
|
|
@click=${this._goBack}
|
|
></ha-icon-button-arrow-prev>
|
|
`
|
|
: html`
|
|
<ha-menu-button
|
|
slot="navigationIcon"
|
|
.hass=${this.hass}
|
|
.narrow=${this.narrow}
|
|
></ha-menu-button>
|
|
`}
|
|
<div slot="title">${this.hass.localize("panel.history")}</div>
|
|
${entitiesSelected
|
|
? html`
|
|
<ha-icon-button
|
|
slot="actionItems"
|
|
@click=${this._removeAll}
|
|
.disabled=${this._isLoading}
|
|
.path=${mdiFilterRemove}
|
|
.label=${this.hass.localize("ui.panel.history.remove_all")}
|
|
></ha-icon-button>
|
|
`
|
|
: ""}
|
|
<ha-icon-button
|
|
slot="actionItems"
|
|
@click=${this._downloadHistory}
|
|
.disabled=${this._isLoading}
|
|
.path=${mdiDownload}
|
|
.label=${this.hass.localize("ui.panel.history.download_data")}
|
|
></ha-icon-button>
|
|
|
|
<div class="flex content">
|
|
<div class="filters">
|
|
<ha-date-range-picker
|
|
.hass=${this.hass}
|
|
?disabled=${this._isLoading}
|
|
.startDate=${this._startDate}
|
|
.endDate=${this._endDate}
|
|
extendedPresets
|
|
@change=${this._dateRangeChanged}
|
|
></ha-date-range-picker>
|
|
<ha-target-picker
|
|
.hass=${this.hass}
|
|
.value=${this._targetPickerValue}
|
|
.disabled=${this._isLoading}
|
|
addOnTop
|
|
@value-changed=${this._targetsChanged}
|
|
></ha-target-picker>
|
|
</div>
|
|
${this._isLoading
|
|
? html`<div class="progress-wrapper">
|
|
<ha-circular-progress indeterminate></ha-circular-progress>
|
|
</div>`
|
|
: !entitiesSelected
|
|
? html`<div class="start-search">
|
|
${this.hass.localize("ui.panel.history.start_search")}
|
|
</div>`
|
|
: html`
|
|
<state-history-charts
|
|
.hass=${this.hass}
|
|
.historyData=${this._mungedStateHistory}
|
|
.startTime=${this._startDate}
|
|
.endTime=${this._endDate}
|
|
>
|
|
</state-history-charts>
|
|
`}
|
|
</div>
|
|
</ha-top-app-bar-fixed>
|
|
`;
|
|
}
|
|
|
|
private mergeHistoryResults(
|
|
ltsResult: HistoryResult,
|
|
historyResult: HistoryResult
|
|
): HistoryResult {
|
|
const result: HistoryResult = { ...historyResult, line: [] };
|
|
|
|
const lookup: Record<
|
|
string,
|
|
{ historyItem?: LineChartUnit; ltsItem?: LineChartUnit }
|
|
> = {};
|
|
|
|
for (const item of historyResult.line) {
|
|
const key = computeGroupKey(item.unit, item.device_class, true);
|
|
if (key) {
|
|
lookup[key] = {
|
|
historyItem: item,
|
|
};
|
|
}
|
|
}
|
|
|
|
for (const item of ltsResult.line) {
|
|
const key = computeGroupKey(item.unit, item.device_class, true);
|
|
if (!key) {
|
|
continue;
|
|
}
|
|
if (key in lookup) {
|
|
lookup[key].ltsItem = item;
|
|
} else {
|
|
lookup[key] = { ltsItem: item };
|
|
}
|
|
}
|
|
|
|
for (const { historyItem, ltsItem } of Object.values(lookup)) {
|
|
if (!historyItem || !ltsItem) {
|
|
// Only one result has data for this item, so just push it directly instead of merging.
|
|
result.line.push(historyItem || ltsItem!);
|
|
continue;
|
|
}
|
|
|
|
const newLineItem: LineChartUnit = { ...historyItem, data: [] };
|
|
const entities = new Set([
|
|
...historyItem.data.map((d) => d.entity_id),
|
|
...ltsItem.data.map((d) => d.entity_id),
|
|
]);
|
|
|
|
for (const entity of entities) {
|
|
const historyDataItem = historyItem.data.find(
|
|
(d) => d.entity_id === entity
|
|
);
|
|
const ltsDataItem = ltsItem.data.find((d) => d.entity_id === entity);
|
|
|
|
if (!historyDataItem || !ltsDataItem) {
|
|
newLineItem.data.push(historyDataItem || ltsDataItem!);
|
|
continue;
|
|
}
|
|
|
|
// Remove statistics that overlap with states
|
|
const oldestState =
|
|
historyDataItem.states[0]?.last_changed ||
|
|
// If no state, fall back to the max last changed of the last statistics (so approve all)
|
|
ltsDataItem.statistics![ltsDataItem.statistics!.length - 1]
|
|
.last_changed + 1;
|
|
|
|
const statistics: LineChartState[] = [];
|
|
for (const s of ltsDataItem.statistics!) {
|
|
if (s.last_changed >= oldestState) {
|
|
break;
|
|
}
|
|
statistics.push(s);
|
|
}
|
|
|
|
newLineItem.data.push(
|
|
statistics.length === 0
|
|
? // All statistics overlapped with states, so just push the states
|
|
historyDataItem
|
|
: {
|
|
...historyDataItem,
|
|
statistics,
|
|
}
|
|
);
|
|
}
|
|
result.line.push(newLineItem);
|
|
}
|
|
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;
|
|
}
|
|
|
|
const searchParams = extractSearchParamsObject();
|
|
const entityIds = searchParams.entity_id;
|
|
const deviceIds = searchParams.device_id;
|
|
const areaIds = searchParams.area_id;
|
|
if (entityIds || deviceIds || areaIds) {
|
|
this._targetPickerValue = {};
|
|
}
|
|
if (entityIds) {
|
|
const splitIds = entityIds.split(",");
|
|
this._targetPickerValue!.entity_id = splitIds;
|
|
}
|
|
if (deviceIds) {
|
|
const splitIds = deviceIds.split(",");
|
|
this._targetPickerValue!.device_id = splitIds;
|
|
}
|
|
if (areaIds) {
|
|
const splitIds = areaIds.split(",");
|
|
this._targetPickerValue!.area_id = splitIds;
|
|
}
|
|
|
|
const startDate = searchParams.start_date;
|
|
if (startDate) {
|
|
this._startDate = new Date(startDate);
|
|
}
|
|
const endDate = searchParams.end_date;
|
|
if (endDate) {
|
|
this._endDate = new Date(endDate);
|
|
}
|
|
}
|
|
|
|
protected firstUpdated(changedProps: PropertyValues) {
|
|
super.firstUpdated(changedProps);
|
|
const searchParams = extractSearchParamsObject();
|
|
if (searchParams.back === "1" && history.length > 1) {
|
|
this._showBack = true;
|
|
navigate(constructUrlCurrentPath(removeSearchParam("back")), {
|
|
replace: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
protected updated(changedProps: PropertyValues) {
|
|
if (
|
|
changedProps.has("_startDate") ||
|
|
changedProps.has("_endDate") ||
|
|
changedProps.has("_targetPickerValue") ||
|
|
(!this._stateHistory &&
|
|
(changedProps.has("_deviceEntityLookup") ||
|
|
changedProps.has("_areaEntityLookup") ||
|
|
changedProps.has("_areaDeviceLookup")))
|
|
) {
|
|
this._getHistory();
|
|
this._getStats();
|
|
}
|
|
}
|
|
|
|
private _removeAll() {
|
|
this._targetPickerValue = {};
|
|
this._updatePath();
|
|
}
|
|
|
|
private async _getStats() {
|
|
const statisticIds = this._getEntityIds();
|
|
|
|
if (statisticIds.length === 0) {
|
|
this._statisticsHistory = undefined;
|
|
return;
|
|
}
|
|
|
|
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,
|
|
true
|
|
);
|
|
// remap states array to statistics array
|
|
(this._statisticsHistory?.line || []).forEach((item) => {
|
|
item.data.forEach((data) => {
|
|
data.statistics = data.states;
|
|
data.states = [];
|
|
});
|
|
});
|
|
}
|
|
|
|
private async _getHistory() {
|
|
const entityIds = this._getEntityIds();
|
|
|
|
if (entityIds.length === 0) {
|
|
this._stateHistory = undefined;
|
|
return;
|
|
}
|
|
|
|
this._isLoading = true;
|
|
|
|
if (this._subscribed) {
|
|
this._unsubscribeHistory();
|
|
}
|
|
|
|
const now = new Date();
|
|
|
|
const { numeric_device_classes: sensorNumericDeviceClasses } =
|
|
await getSensorNumericDeviceClasses(this.hass);
|
|
|
|
this._subscribed = subscribeHistory(
|
|
this.hass,
|
|
(history) => {
|
|
this._isLoading = false;
|
|
this._stateHistory = computeHistory(
|
|
this.hass,
|
|
history,
|
|
entityIds,
|
|
this.hass.localize,
|
|
sensorNumericDeviceClasses,
|
|
true
|
|
);
|
|
},
|
|
this._startDate,
|
|
this._endDate,
|
|
entityIds
|
|
);
|
|
this._subscribed.catch(() => {
|
|
this._isLoading = false;
|
|
this._unsubscribeHistory();
|
|
});
|
|
if (this._endDate > now) {
|
|
this._setRedrawTimer();
|
|
}
|
|
}
|
|
|
|
private _setRedrawTimer() {
|
|
clearInterval(this._interval);
|
|
const now = new Date();
|
|
const end = this._endDate > now ? now : this._endDate;
|
|
const timespan = differenceInHours(end, this._startDate);
|
|
this._interval = window.setInterval(
|
|
() => this._stateHistoryCharts?.requestUpdate(),
|
|
// if timespan smaller than 1 hour, update every 10 seconds, smaller than 5 hours, redraw every minute, otherwise every 5 minutes
|
|
timespan < 2
|
|
? 10000
|
|
: timespan < 10
|
|
? 60 * 1000
|
|
: MIN_TIME_BETWEEN_UPDATES
|
|
);
|
|
}
|
|
|
|
private _unsubscribeHistory() {
|
|
if (this._interval) {
|
|
clearInterval(this._interval);
|
|
this._interval = undefined;
|
|
}
|
|
if (this._subscribed) {
|
|
this._subscribed.then((unsub) => unsub?.());
|
|
this._subscribed = undefined;
|
|
}
|
|
}
|
|
|
|
private _getEntityIds(): string[] {
|
|
return this.__getEntityIds(
|
|
this._targetPickerValue,
|
|
this._deviceEntityLookup,
|
|
this._areaEntityLookup,
|
|
this._areaDeviceLookup
|
|
);
|
|
}
|
|
|
|
private __getEntityIds = memoizeOne(
|
|
(
|
|
targetPickerValue: HassServiceTarget,
|
|
deviceEntityLookup: DeviceEntityLookup | undefined,
|
|
areaEntityLookup: AreaEntityLookup | undefined,
|
|
areaDeviceLookup: AreaDeviceLookup | undefined
|
|
): string[] => {
|
|
if (
|
|
!targetPickerValue ||
|
|
deviceEntityLookup === undefined ||
|
|
areaEntityLookup === undefined ||
|
|
areaDeviceLookup === undefined
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
const entityIds = new Set<string>();
|
|
let {
|
|
area_id: searchingAreaId,
|
|
device_id: searchingDeviceId,
|
|
entity_id: searchingEntityId,
|
|
} = targetPickerValue;
|
|
|
|
if (searchingAreaId) {
|
|
searchingAreaId = ensureArray(searchingAreaId);
|
|
for (const singleSearchingAreaId of searchingAreaId) {
|
|
const foundEntities = areaEntityLookup[singleSearchingAreaId];
|
|
if (foundEntities?.length) {
|
|
for (const foundEntity of foundEntities) {
|
|
if (foundEntity.entity_category === null) {
|
|
entityIds.add(foundEntity.entity_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
const foundDevices = areaDeviceLookup[singleSearchingAreaId];
|
|
if (!foundDevices?.length) {
|
|
continue;
|
|
}
|
|
|
|
for (const foundDevice of foundDevices) {
|
|
const foundDeviceEntities = deviceEntityLookup[foundDevice.id];
|
|
if (!foundDeviceEntities?.length) {
|
|
continue;
|
|
}
|
|
|
|
for (const foundDeviceEntity of foundDeviceEntities) {
|
|
if (
|
|
(!foundDeviceEntity.area_id ||
|
|
foundDeviceEntity.area_id === singleSearchingAreaId) &&
|
|
foundDeviceEntity.entity_category === null
|
|
) {
|
|
entityIds.add(foundDeviceEntity.entity_id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (searchingDeviceId) {
|
|
searchingDeviceId = ensureArray(searchingDeviceId);
|
|
for (const singleSearchingDeviceId of searchingDeviceId) {
|
|
const foundEntities = deviceEntityLookup[singleSearchingDeviceId];
|
|
if (!foundEntities?.length) {
|
|
continue;
|
|
}
|
|
|
|
for (const foundEntity of foundEntities) {
|
|
if (foundEntity.entity_category === null) {
|
|
entityIds.add(foundEntity.entity_id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (searchingEntityId) {
|
|
searchingEntityId = ensureArray(searchingEntityId);
|
|
for (const singleSearchingEntityId of searchingEntityId) {
|
|
entityIds.add(singleSearchingEntityId);
|
|
}
|
|
}
|
|
|
|
return [...entityIds];
|
|
}
|
|
);
|
|
|
|
private _dateRangeChanged(ev) {
|
|
this._startDate = ev.detail.startDate;
|
|
const endDate = ev.detail.endDate;
|
|
if (endDate.getHours() === 0 && endDate.getMinutes() === 0) {
|
|
endDate.setDate(endDate.getDate() + 1);
|
|
endDate.setMilliseconds(endDate.getMilliseconds() - 1);
|
|
}
|
|
this._endDate = endDate;
|
|
|
|
this._updatePath();
|
|
}
|
|
|
|
private _targetsChanged(ev) {
|
|
this._targetPickerValue = ev.detail.value || {};
|
|
this._updatePath();
|
|
}
|
|
|
|
private _updatePath() {
|
|
const params: Record<string, string> = {};
|
|
|
|
if (this._targetPickerValue.entity_id) {
|
|
params.entity_id = ensureArray(this._targetPickerValue.entity_id).join(
|
|
","
|
|
);
|
|
}
|
|
if (this._targetPickerValue.area_id) {
|
|
params.area_id = ensureArray(this._targetPickerValue.area_id).join(",");
|
|
}
|
|
if (this._targetPickerValue.device_id) {
|
|
params.device_id = ensureArray(this._targetPickerValue.device_id).join(
|
|
","
|
|
);
|
|
}
|
|
|
|
if (this._startDate) {
|
|
params.start_date = this._startDate.toISOString();
|
|
}
|
|
|
|
if (this._endDate) {
|
|
params.end_date = this._endDate.toISOString();
|
|
}
|
|
|
|
navigate(`/history?${createSearchParam(params)}`, { replace: true });
|
|
}
|
|
|
|
private _downloadHistory() {
|
|
// Make a copy because getEntityIDs is memoized and sort works in-place
|
|
const entities = [...this._getEntityIds()].sort();
|
|
if (entities.length === 0 || !this._mungedStateHistory) {
|
|
showAlertDialog(this, {
|
|
title: this.hass.localize("ui.panel.history.download_data_error"),
|
|
text: this.hass.localize("ui.panel.history.error_no_data"),
|
|
warning: true,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const csv: string[] = [""]; // headers will be replaced later.
|
|
const headers = ["entity_id", "state", "last_changed"];
|
|
const processedDomainAttributes = new Set<string>();
|
|
const domainAttributes: Record<string, Record<string, number>> = {
|
|
climate: {
|
|
current_temperature: 0,
|
|
hvac_action: 0,
|
|
target_temp_high: 0,
|
|
target_temp_low: 0,
|
|
temperature: 0,
|
|
},
|
|
humidifier: {
|
|
action: 0,
|
|
current_humidity: 0,
|
|
humidity: 0,
|
|
},
|
|
water_heater: {
|
|
current_temperature: 0,
|
|
operation_mode: 0,
|
|
temperature: 0,
|
|
},
|
|
};
|
|
const formatDate = (number) => new Date(number).toISOString();
|
|
|
|
for (const line of this._mungedStateHistory.line) {
|
|
for (const entity of line.data) {
|
|
const entityId = entity.entity_id;
|
|
const domain = computeDomain(entityId);
|
|
const extraAttributes = domainAttributes[domain];
|
|
|
|
// Add extra attributes to headers if needed
|
|
if (extraAttributes && !processedDomainAttributes.has(domain)) {
|
|
processedDomainAttributes.add(domain);
|
|
let index = headers.length;
|
|
for (const attr of Object.keys(extraAttributes)) {
|
|
headers.push(attr);
|
|
extraAttributes[attr] = index;
|
|
index += 1;
|
|
}
|
|
}
|
|
|
|
if (entity.statistics) {
|
|
for (const s of entity.statistics) {
|
|
csv.push(`${entityId},${s.state},${formatDate(s.last_changed)}\n`);
|
|
}
|
|
}
|
|
|
|
for (const s of entity.states) {
|
|
const lastChanged = formatDate(s.last_changed);
|
|
const data = [entityId, s.state, lastChanged];
|
|
|
|
if (s.attributes && extraAttributes) {
|
|
const attrs = s.attributes;
|
|
for (const [attr, index] of Object.entries(extraAttributes)) {
|
|
if (attr in attrs) {
|
|
data[index] = attrs[attr];
|
|
}
|
|
}
|
|
}
|
|
|
|
csv.push(data.join(",") + "\n");
|
|
}
|
|
}
|
|
}
|
|
for (const timeline of this._mungedStateHistory.timeline) {
|
|
const entityId = timeline.entity_id;
|
|
for (const s of timeline.data) {
|
|
const safeState = /,|"/.test(s.state)
|
|
? `"${s.state.replaceAll('"', '""')}"`
|
|
: s.state;
|
|
csv.push(`${entityId},${safeState},${formatDate(s.last_changed)}\n`);
|
|
}
|
|
}
|
|
csv[0] = headers.join(",") + "\n";
|
|
const blob = new Blob(csv, {
|
|
type: "text/csv",
|
|
});
|
|
const url = window.URL.createObjectURL(blob);
|
|
fileDownload(url, "history.csv");
|
|
}
|
|
|
|
static get styles() {
|
|
return [
|
|
haStyle,
|
|
css`
|
|
.content {
|
|
padding: 0 16px 16px;
|
|
padding-bottom: max(env(safe-area-inset-bottom), 16px);
|
|
}
|
|
|
|
:host([virtualize]) {
|
|
height: 100%;
|
|
}
|
|
|
|
.progress-wrapper {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
flex-direction: column;
|
|
padding: 16px;
|
|
}
|
|
|
|
.filters {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
margin-top: 16px;
|
|
}
|
|
|
|
ha-date-range-picker {
|
|
margin-right: 16px;
|
|
margin-inline-end: 16px;
|
|
margin-inline-start: initial;
|
|
max-width: 100%;
|
|
direction: var(--direction);
|
|
}
|
|
|
|
@media all and (max-width: 1025px) {
|
|
.filters {
|
|
flex-direction: column;
|
|
}
|
|
ha-date-range-picker {
|
|
margin-right: 0;
|
|
margin-inline-end: 0;
|
|
margin-inline-start: initial;
|
|
width: 100%;
|
|
}
|
|
}
|
|
|
|
.start-search {
|
|
padding-top: 16px;
|
|
text-align: center;
|
|
color: var(--secondary-text-color);
|
|
}
|
|
`,
|
|
];
|
|
}
|
|
}
|
|
|
|
customElements.define("ha-panel-history", HaPanelHistory);
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
"ha-panel-history": HaPanelHistory;
|
|
}
|
|
}
|