From 1078bb4287152c572279037c2679148fe4a910bc Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 16 Jul 2021 01:16:02 +0200 Subject: [PATCH] Add `statistics-graph-card` (#9479) * Add `statistics-graph-card` * Make variable names clearer --- .../chart/state-history-chart-line.ts | 19 +- .../chart/state-history-chart-timeline.ts | 2 +- src/components/chart/state-history-charts.ts | 1 - src/components/chart/statistics-chart.ts | 308 ++++++++++++++++++ src/data/history.ts | 56 +++- .../lovelace/cards/hui-history-graph-card.ts | 6 +- .../cards/hui-statistics-graph-card.ts | 182 +++++++++++ src/panels/lovelace/cards/types.ts | 9 + .../create-element/create-card-element.ts | 4 +- 9 files changed, 567 insertions(+), 20 deletions(-) create mode 100644 src/components/chart/statistics-chart.ts create mode 100644 src/panels/lovelace/cards/hui-statistics-graph-card.ts diff --git a/src/components/chart/state-history-chart-line.ts b/src/components/chart/state-history-chart-line.ts index 9b0a704f86..e08743640e 100644 --- a/src/components/chart/state-history-chart-line.ts +++ b/src/components/chart/state-history-chart-line.ts @@ -6,12 +6,17 @@ import { LineChartEntity, LineChartState } from "../../data/history"; import { HomeAssistant } from "../../types"; import "./ha-chart-base"; +const safeParseFloat = (value) => { + const parsed = parseFloat(value); + return isFinite(parsed) ? parsed : null; +}; + class StateHistoryChartLine extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public data: LineChartEntity[] = []; - @property({ type: Boolean }) public names = false; + @property() public names: boolean | Record = false; @property() public unit?: string; @@ -122,21 +127,15 @@ class StateHistoryChartLine extends LitElement { return; } - function safeParseFloat(value) { - const parsed = parseFloat(value); - return isFinite(parsed) ? parsed : null; - } - endTime = this.endTime || // Get the highest date from the last date of each device new Date( - Math.max.apply( - null, - deviceStates.map((devSts) => + Math.max( + ...deviceStates.map((devSts) => new Date( devSts.states[devSts.states.length - 1].last_changed - ).getMilliseconds() + ).getTime() ) ) ); diff --git a/src/components/chart/state-history-chart-timeline.ts b/src/components/chart/state-history-chart-timeline.ts index d2af0deb9f..2ce6277871 100644 --- a/src/components/chart/state-history-chart-timeline.ts +++ b/src/components/chart/state-history-chart-timeline.ts @@ -79,7 +79,7 @@ export class StateHistoryChartTimeline extends LitElement { @property({ attribute: false }) public data: TimelineEntity[] = []; - @property({ type: Boolean }) public names = false; + @property() public names: boolean | Record = false; @property() public unit?: string; diff --git a/src/components/chart/state-history-charts.ts b/src/components/chart/state-history-charts.ts index c31559c838..ae5d7844eb 100644 --- a/src/components/chart/state-history-charts.ts +++ b/src/components/chart/state-history-charts.ts @@ -10,7 +10,6 @@ import { customElement, property } from "lit/decorators"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { HistoryResult } from "../../data/history"; import type { HomeAssistant } from "../../types"; -import "../ha-circular-progress"; import "./state-history-chart-line"; import "./state-history-chart-timeline"; diff --git a/src/components/chart/statistics-chart.ts b/src/components/chart/statistics-chart.ts new file mode 100644 index 0000000000..13e5e92a58 --- /dev/null +++ b/src/components/chart/statistics-chart.ts @@ -0,0 +1,308 @@ +import type { + ChartData, + ChartDataset, + ChartOptions, + ChartType, +} from "chart.js"; +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { getColorByIndex } from "../../common/color/colors"; +import { isComponentLoaded } from "../../common/config/is_component_loaded"; +import { computeStateName } from "../../common/entity/compute_state_name"; +import { + Statistics, + statisticsHaveType, + StatisticType, +} from "../../data/history"; +import type { HomeAssistant } from "../../types"; +import "./ha-chart-base"; + +@customElement("statistics-chart") +class StatisticsChart extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public statisticsData!: Statistics; + + @property() public names: boolean | Record = false; + + @property({ attribute: false }) public endTime?: Date; + + @property({ type: Array }) public statTypes: Array = [ + "sum", + "min", + "max", + "mean", + ]; + + @property() public chartType: ChartType = "line"; + + @property({ type: Boolean }) public isLoadingData = false; + + @state() private _chartData?: ChartData; + + @state() private _chartOptions?: ChartOptions; + + protected shouldUpdate(changedProps: PropertyValues): boolean { + return !(changedProps.size === 1 && changedProps.has("hass")); + } + + public willUpdate(changedProps: PropertyValues) { + if (!this.hasUpdated) { + this._createOptions(); + } + if (changedProps.has("statisticsData")) { + this._generateData(); + } + } + + protected render(): TemplateResult { + if (!isComponentLoaded(this.hass, "history")) { + return html`
+ ${this.hass.localize("ui.components.history_charts.history_disabled")} +
`; + } + + if (this.isLoadingData && !this.statisticsData) { + return html`
+ ${this.hass.localize( + "ui.components.statistics_charts.loading_statistics" + )} +
`; + } + + if (!this.statisticsData || !Object.keys(this.statisticsData).length) { + return html`
+ ${this.hass.localize( + "ui.components.statistics_charts.no_statistics_found" + )} +
`; + } + + return html` + + `; + } + + private _createOptions() { + this._chartOptions = { + parsing: false, + animation: false, + scales: { + x: { + type: "time", + adapters: { + date: { + locale: this.hass.locale, + }, + }, + ticks: { + maxRotation: 0, + sampleSize: 5, + autoSkipPadding: 20, + major: { + enabled: true, + }, + font: (context) => + context.tick && context.tick.major + ? ({ weight: "bold" } as any) + : {}, + }, + time: { + tooltipFormat: "datetimeseconds", + }, + }, + y: { + ticks: { + maxTicksLimit: 7, + }, + }, + }, + plugins: { + tooltip: { + mode: "nearest", + callbacks: { + label: (context) => `${context.dataset.label}: ${context.parsed.y}`, + }, + }, + filler: { + propagate: true, + }, + legend: { + display: false, + labels: { + usePointStyle: true, + }, + }, + }, + hover: { + mode: "nearest", + }, + elements: { + line: { + tension: 0.4, + borderWidth: 1.5, + }, + point: { + hitRadius: 5, + }, + }, + }; + } + + private _generateData() { + let colorIndex = 0; + const statisticsData = Object.values(this.statisticsData); + const totalDataSets: ChartDataset<"line">[] = []; + let endTime: Date; + + if (statisticsData.length === 0) { + return; + } + + endTime = + this.endTime || + // Get the highest date from the last date of each statistic + new Date( + Math.max( + ...statisticsData.map((stats) => + new Date(stats[stats.length - 1].start).getTime() + ) + ) + ); + + if (endTime > new Date()) { + endTime = new Date(); + } + + const names = this.names || {}; + statisticsData.forEach((stats) => { + const firstStat = stats[0]; + let name = names[firstStat.statistic_id]; + if (!name) { + const entityState = this.hass.states[firstStat.statistic_id]; + if (entityState) { + name = computeStateName(entityState); + } else { + name = firstStat.statistic_id; + } + } + // array containing [value1, value2, etc] + let prevValues: Array | null = null; + + // The datasets for the current statistic + const statDataSets: ChartDataset<"line">[] = []; + + const pushData = ( + timestamp: Date, + dataValues: Array | null + ) => { + if (!dataValues) return; + if (timestamp > endTime) { + // Drop datapoints that are after the requested endTime. This could happen if + // endTime is "now" and client time is not in sync with server time. + return; + } + statDataSets.forEach((d, i) => { + if (dataValues[i] === null && prevValues && prevValues[i] !== null) { + // null data values show up as gaps in the chart. + // If the current value for the dataset is null and the previous + // value of the data set is not null, then add an 'end' point + // to the chart for the previous value. Otherwise the gap will + // be too big. It will go from the start of the previous data + // value until the start of the next data value. + d.data.push({ x: timestamp.getTime(), y: prevValues[i]! }); + } + d.data.push({ x: timestamp.getTime(), y: dataValues[i]! }); + }); + prevValues = dataValues; + }; + + const addDataSet = ( + nameY: string, + step = false, + fill = false, + color?: string + ) => { + if (!color) { + color = getColorByIndex(colorIndex); + colorIndex++; + } + statDataSets.push({ + label: nameY, + fill: fill ? "origin" : false, + borderColor: color, + backgroundColor: color + "7F", + stepped: step ? "before" : false, + pointRadius: 0, + data: [], + }); + }; + + const statTypes: this["statTypes"] = []; + + this.statTypes.forEach((type) => { + if (statisticsHaveType(stats, type)) { + statTypes.push(type); + addDataSet( + `${name} (${this.hass.localize( + `ui.components.statistics_charts.statistic_types.${type}` + )})`, + false + ); + } + }); + + // Process chart data. + stats.forEach((stat) => { + const dataValues: Array = []; + statTypes.forEach((type) => { + const val = stat[type]; + dataValues.push(val !== null ? Math.round(val * 100) / 100 : null); + }); + const date = new Date(stat.start); + pushData(date, dataValues); + }); + + // Add an entry for final values + pushData(endTime, prevValues); + + // Concat two arrays + Array.prototype.push.apply(totalDataSets, statDataSets); + }); + + this._chartData = { + datasets: totalDataSets, + }; + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: block; + min-height: 60px; + } + .info { + text-align: center; + line-height: 60px; + color: var(--secondary-text-color); + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "statistics-chart": StatisticsChart; + } +} diff --git a/src/data/history.ts b/src/data/history.ts index 636698caf7..80e9185d49 100644 --- a/src/data/history.ts +++ b/src/data/history.ts @@ -53,11 +53,28 @@ export interface HistoryResult { timeline: TimelineEntity[]; } +export type StatisticType = "sum" | "min" | "max" | "mean"; + +export interface Statistics { + [statisticId: string]: StatisticValue[]; +} + +export interface StatisticValue { + statistic_id: string; + start: string; + last_reset: string | null; + max: number | null; + mean: number | null; + min: number | null; + sum: number | null; + state: number | null; +} + export const fetchRecent = ( - hass, - entityId, - startTime, - endTime, + hass: HomeAssistant, + entityId: string, + startTime: Date, + endTime: Date, skipInitialState = false, significantChangesOnly?: boolean, minimalResponse = true @@ -87,7 +104,7 @@ export const fetchDate = ( hass: HomeAssistant, startTime: Date, endTime: Date, - entityId + entityId?: string ): Promise => hass.callApi( "GET", @@ -252,3 +269,32 @@ export const computeHistory = ( return { line: unitStates, timeline: timelineDevices }; }; + +// Statistics + +export const getStatisticIds = ( + hass: HomeAssistant, + statistic_type?: "mean" | "sum" +) => + hass.callWS({ + type: "history/list_statistic_ids", + statistic_type, + }); + +export const fetchStatistics = ( + hass: HomeAssistant, + startTime: Date, + endTime?: Date, + statistic_ids?: string[] +) => + hass.callWS({ + type: "history/statistics_during_period", + start_time: startTime.toISOString(), + end_time: endTime?.toISOString(), + statistic_ids, + }); + +export const statisticsHaveType = ( + stats: StatisticValue[], + type: StatisticType +) => stats.some((stat) => stat[type] !== null); diff --git a/src/panels/lovelace/cards/hui-history-graph-card.ts b/src/panels/lovelace/cards/hui-history-graph-card.ts index b61f3c0512..cfd872d7c5 100644 --- a/src/panels/lovelace/cards/hui-history-graph-card.ts +++ b/src/panels/lovelace/cards/hui-history-graph-card.ts @@ -63,7 +63,6 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard { throw new Error("You must include at least one entity"); } - this._config = config; this._configEntities = config.entities ? processConfigEntities(config.entities) : []; @@ -85,9 +84,14 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard { cacheKey: _entities.join(), hoursToShow: config.hours_to_show || 24, }; + + this._config = config; } protected shouldUpdate(changedProps: PropertyValues): boolean { + if (changedProps.has("_stateHistory")) { + return true; + } return hasConfigOrEntitiesChanged(this, changedProps); } diff --git a/src/panels/lovelace/cards/hui-statistics-graph-card.ts b/src/panels/lovelace/cards/hui-statistics-graph-card.ts new file mode 100644 index 0000000000..b475097634 --- /dev/null +++ b/src/panels/lovelace/cards/hui-statistics-graph-card.ts @@ -0,0 +1,182 @@ +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import "../../../components/ha-card"; +import "../../../components/chart/statistics-chart"; +import { HomeAssistant } from "../../../types"; +import { hasConfigOrEntitiesChanged } from "../common/has-changed"; +import { processConfigEntities } from "../common/process-config-entities"; +import { LovelaceCard } from "../types"; +import { StatisticsGraphCardConfig } from "./types"; +import { fetchStatistics, Statistics } from "../../../data/history"; + +@customElement("hui-statistics-graph-card") +export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard { + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _statistics?: Statistics; + + @state() private _config?: StatisticsGraphCardConfig; + + private _entities: string[] = []; + + private _names: Record = {}; + + private _fetching = false; + + private _interval?: number; + + public disconnectedCallback() { + super.disconnectedCallback(); + if (this._interval) { + clearInterval(this._interval); + this._interval = undefined; + } + } + + public connectedCallback() { + super.connectedCallback(); + if (!this.hasUpdated) { + return; + } + this._getStatistics(); + // statistics are created every hour + clearInterval(this._interval); + this._interval = window.setInterval( + () => this._getStatistics(), + 1000 * 60 * 60 + ); + } + + public getCardSize(): number { + return this._config?.title ? 2 : 0 + 2 * (this._entities?.length || 1); + } + + public setConfig(config: StatisticsGraphCardConfig): void { + if (!config.entities || !Array.isArray(config.entities)) { + throw new Error("Entities need to be an array"); + } + + if (!config.entities.length) { + throw new Error("You must include at least one entity"); + } + + const configEntities = config.entities + ? processConfigEntities(config.entities) + : []; + + configEntities.forEach((entity) => { + this._entities.push(entity.entity); + if (entity.name) { + this._names[entity.entity] = entity.name; + } + }); + + if (typeof config.stat_types === "string") { + this._config = { ...config, stat_types: [config.stat_types] }; + } else if (!config.stat_types) { + this._config = { ...config, stat_types: ["sum", "min", "max", "mean"] }; + } else { + this._config = config; + } + } + + protected shouldUpdate(changedProps: PropertyValues): boolean { + if (changedProps.has("_statistics")) { + return true; + } + return hasConfigOrEntitiesChanged(this, changedProps); + } + + public willUpdate(changedProps: PropertyValues) { + super.willUpdate(changedProps); + if (!this._config || !changedProps.has("_config")) { + return; + } + + const oldConfig = changedProps.get("_config") as + | StatisticsGraphCardConfig + | undefined; + + if (oldConfig?.entities !== this._config.entities) { + this._getStatistics(); + // statistics are created every hour + clearInterval(this._interval); + this._interval = window.setInterval( + () => this._getStatistics(), + 1000 * 60 * 60 + ); + } + } + + protected render(): TemplateResult { + if (!this.hass || !this._config) { + return html``; + } + + return html` + +
+ +
+
+ `; + } + + private async _getStatistics(): Promise { + if (this._fetching) { + return; + } + const startDate = new Date(); + startDate.setHours(-24 * (this._config!.days_to_show || 30)); + this._fetching = true; + try { + this._statistics = await fetchStatistics( + this.hass!, + startDate, + undefined, + this._entities + ); + } finally { + this._fetching = false; + } + } + + static get styles(): CSSResultGroup { + return css` + ha-card { + height: 100%; + } + .content { + padding: 16px; + } + .has-header { + padding-top: 0; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-statistics-graph-card": HuiStatisticsGraphCard; + } +} diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index db5c4063e4..c68582450c 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -1,3 +1,4 @@ +import { StatisticType } from "../../../data/history"; import { ActionConfig, LovelaceCardConfig } from "../../../data/lovelace"; import { FullCalendarView } from "../../../types"; import { Condition } from "../common/validate-condition"; @@ -210,6 +211,14 @@ export interface HistoryGraphCardConfig extends LovelaceCardConfig { title?: string; } +export interface StatisticsGraphCardConfig extends LovelaceCardConfig { + title?: string; + entities: Array; + days_to_show?: number; + stat_types?: StatisticType | StatisticType[]; + chart_type?: "line" | "bar"; +} + export interface PictureCardConfig extends LovelaceCardConfig { image?: string; tap_action?: ActionConfig; diff --git a/src/panels/lovelace/create-element/create-card-element.ts b/src/panels/lovelace/create-element/create-card-element.ts index 3f2a398acd..b50de9a7c0 100644 --- a/src/panels/lovelace/create-element/create-card-element.ts +++ b/src/panels/lovelace/create-element/create-card-element.ts @@ -5,7 +5,6 @@ import "../cards/hui-entities-card"; import "../cards/hui-entity-button-card"; import "../cards/hui-entity-card"; import "../cards/hui-glance-card"; -import "../cards/hui-history-graph-card"; import "../cards/hui-horizontal-stack-card"; import "../cards/hui-light-card"; import "../cards/hui-sensor-card"; @@ -24,7 +23,6 @@ const ALWAYS_LOADED_TYPES = new Set([ "button", "entity-button", "glance", - "history-graph", "horizontal-stack", "light", "sensor", @@ -50,6 +48,8 @@ const LAZY_LOAD_TYPES = { "shopping-list": () => import("../cards/hui-shopping-list-card"), conditional: () => import("../cards/hui-conditional-card"), gauge: () => import("../cards/hui-gauge-card"), + "history-graph": () => import("../cards/hui-history-graph-card"), + "statistics-graph": () => import("../cards/hui-statistics-graph-card"), iframe: () => import("../cards/hui-iframe-card"), map: () => import("../cards/hui-map-card"), markdown: () => import("../cards/hui-markdown-card"),