diff --git a/src/components/ha-selector/ha-selector-statistic.ts b/src/components/ha-selector/ha-selector-statistic.ts new file mode 100644 index 0000000000..360075069a --- /dev/null +++ b/src/components/ha-selector/ha-selector-statistic.ts @@ -0,0 +1,53 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import type { StatisticSelector } from "../../data/selector"; +import { HomeAssistant } from "../../types"; +import "../entity/ha-statistics-picker"; + +@customElement("ha-selector-statistic") +export class HaStatisticSelector extends LitElement { + @property() public hass!: HomeAssistant; + + @property() public selector!: StatisticSelector; + + @property() public value?: any; + + @property() public label?: string; + + @property() public helper?: string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = true; + + protected render() { + if (!this.selector.statistic.multiple) { + return html``; + } + + return html` + ${this.label ? html`` : ""} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-statistic": HaStatisticSelector; + } +} diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index e77a3e7847..09125e10a7 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -17,6 +17,7 @@ const LOAD_ELEMENTS = { device: () => import("./ha-selector-device"), duration: () => import("./ha-selector-duration"), entity: () => import("./ha-selector-entity"), + statistic: () => import("./ha-selector-statistic"), file: () => import("./ha-selector-file"), navigation: () => import("./ha-selector-navigation"), number: () => import("./ha-selector-number"), diff --git a/src/data/recorder.ts b/src/data/recorder.ts index e711cd4670..2d8115cdb3 100644 --- a/src/data/recorder.ts +++ b/src/data/recorder.ts @@ -1,4 +1,5 @@ import { computeStateName } from "../common/entity/compute_state_name"; +import { HaDurationData } from "../components/ha-duration-input"; import { HomeAssistant } from "../types"; export type StatisticType = "state" | "sum" | "min" | "max" | "mean"; @@ -19,6 +20,13 @@ export interface StatisticValue { state: number | null; } +export interface Statistic { + max: number | null; + mean: number | null; + min: number | null; + change: number | null; +} + export interface StatisticsMetaData { statistics_unit_of_measurement: string | null; statistic_id: string; @@ -122,6 +130,36 @@ export const fetchStatistics = ( units, }); +export const fetchStatistic = ( + hass: HomeAssistant, + statistic_id: string, + period: { + fixed_period?: { start: string | Date; end: string | Date }; + calendar?: { period: string; offset: number }; + rolling_window?: { duration: HaDurationData; offset: HaDurationData }; + }, + units?: StatisticsUnitConfiguration +) => + hass.callWS({ + type: "recorder/statistic_during_period", + statistic_id, + units, + fixed_period: period.fixed_period + ? { + start_time: + period.fixed_period.start instanceof Date + ? period.fixed_period.start.toISOString() + : period.fixed_period.start, + end_time: + period.fixed_period.end instanceof Date + ? period.fixed_period.end.toISOString() + : period.fixed_period.end, + } + : undefined, + calendar: period.calendar, + rolling_window: period.rolling_window, + }); + export const validateStatistics = (hass: HomeAssistant) => hass.callWS({ type: "recorder/validate_statistics", diff --git a/src/data/selector.ts b/src/data/selector.ts index 061b246695..a50128c979 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -27,6 +27,7 @@ export type Selector = | ObjectSelector | SelectSelector | StateSelector + | StatisticSelector | StringSelector | TargetSelector | TemplateSelector @@ -133,6 +134,13 @@ export interface EntitySelector { } | null; } +export interface StatisticSelector { + statistic: { + device_class?: string; + multiple?: boolean; + }; +} + export interface FileSelector { file: { accept: string; @@ -195,7 +203,7 @@ export interface ObjectSelector { } export interface SelectOption { - value: string; + value: any; label: string; disabled?: boolean; } diff --git a/src/panels/lovelace/cards/hui-statistic-card.ts b/src/panels/lovelace/cards/hui-statistic-card.ts new file mode 100644 index 0000000000..61c286062b --- /dev/null +++ b/src/panels/lovelace/cards/hui-statistic-card.ts @@ -0,0 +1,316 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { isValidEntityId } from "../../../common/entity/valid_entity_id"; +import { formatNumber } from "../../../common/number/format_number"; +import "../../../components/ha-alert"; +import "../../../components/ha-card"; +import "../../../components/ha-state-icon"; +import { + fetchStatistic, + getDisplayUnit, + getStatisticLabel, + getStatisticMetadata, + isExternalStatistic, + StatisticsMetaData, +} from "../../../data/recorder"; +import { HomeAssistant } from "../../../types"; +import { computeCardSize } from "../common/compute-card-size"; +import { findEntities } from "../common/find-entities"; +import { hasConfigOrEntityChanged } from "../common/has-changed"; +import { createHeaderFooterElement } from "../create-element/create-header-footer-element"; +import { + LovelaceCard, + LovelaceCardEditor, + LovelaceHeaderFooter, +} from "../types"; +import { HuiErrorCard } from "./hui-error-card"; +import { EntityCardConfig, StatisticCardConfig } from "./types"; + +@customElement("hui-statistic-card") +export class HuiStatisticCard extends LitElement implements LovelaceCard { + public static async getConfigElement(): Promise { + await import("../editor/config-elements/hui-statistic-card-editor"); + return document.createElement("hui-statistic-card-editor"); + } + + public static getStubConfig( + hass: HomeAssistant, + entities: string[], + entitiesFill: string[] + ) { + const includeDomains = ["sensor"]; + const maxEntities = 1; + const foundEntities = findEntities( + hass, + maxEntities, + entities, + entitiesFill, + includeDomains, + (stateObj: HassEntity) => "state_class" in stateObj.attributes + ); + + return { + entity: foundEntities[0] || "", + period: { calendar: { period: "month", offset: 0 } }, + }; + } + + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: StatisticCardConfig; + + @state() private _value?: number | null; + + @state() private _metadata?: StatisticsMetaData; + + @state() private _error?: string; + + private _interval?: number; + + private _footerElement?: HuiErrorCard | LovelaceHeaderFooter; + + public disconnectedCallback() { + super.disconnectedCallback(); + clearInterval(this._interval); + } + + public setConfig(config: StatisticCardConfig): void { + if (!config.entity) { + throw new Error("Entity must be specified"); + } + if (!config.stat_type) { + throw new Error("Statistic type must be specified"); + } + if (!config.period) { + throw new Error("Period must be specified"); + } + if ( + config.entity && + !isExternalStatistic(config.entity) && + !isValidEntityId(config.entity) + ) { + throw new Error("Invalid entity"); + } + + this._config = config; + this._error = undefined; + this._fetchStatistic(); + this._fetchMetadata(); + + if (this._config.footer) { + this._footerElement = createHeaderFooterElement(this._config.footer); + } else if (this._footerElement) { + this._footerElement = undefined; + } + } + + public async getCardSize(): Promise { + let size = 2; + if (this._footerElement) { + const footerSize = computeCardSize(this._footerElement); + size += footerSize instanceof Promise ? await footerSize : footerSize; + } + return size; + } + + protected render(): TemplateResult { + if (!this._config || !this.hass) { + return html``; + } + + if (this._error) { + return html` ${this._error} `; + } + + const stateObj = this.hass.states[this._config.entity]; + const name = + this._config.name || + getStatisticLabel(this.hass, this._config.entity, this._metadata); + + return html` + +
+
${name}
+
+ +
+
+
+ ${this._value === undefined + ? "" + : this._value === null + ? "?" + : formatNumber(this._value, this.hass.locale)} + ${this._config.unit || + getDisplayUnit( + this.hass, + this._config.entity, + this._metadata + )} +
+ ${this._footerElement} +
+ `; + } + + protected shouldUpdate(changedProps: PropertyValues): boolean { + // Side Effect used to update footer hass while keeping optimizations + if (this._footerElement) { + this._footerElement.hass = this.hass; + } + if ( + changedProps.has("_value") || + changedProps.has("_metadata") || + changedProps.has("_error") + ) { + return true; + } + if (this._config) { + return hasConfigOrEntityChanged(this, changedProps); + } + return true; + } + + protected firstUpdated() { + this._fetchStatistic(); + this._fetchMetadata(); + } + + protected updated(changedProps: PropertyValues) { + super.updated(changedProps); + if (!this._config || !this.hass) { + return; + } + + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + const oldConfig = changedProps.get("_config") as + | EntityCardConfig + | undefined; + + if ( + !oldHass || + !oldConfig || + oldHass.themes !== this.hass.themes || + oldConfig.theme !== this._config.theme + ) { + applyThemesOnElement(this, this.hass.themes, this._config!.theme); + } + } + + private async _fetchStatistic() { + if (!this.hass || !this._config) { + return; + } + clearInterval(this._interval); + this._interval = window.setInterval( + () => this._fetchStatistic(), + 5 * 1000 * 60 + ); + try { + const stats = await fetchStatistic( + this.hass, + this._config.entity, + this._config.period + ); + this._value = stats[this._config!.stat_type]; + this._error = undefined; + } catch (e: any) { + this._error = e.message; + } + } + + private async _fetchMetadata() { + if (!this.hass || !this._config) { + return; + } + try { + this._metadata = ( + await getStatisticMetadata(this.hass, [this._config.entity]) + )?.[0]; + } catch (e: any) { + this._error = e.message; + } + } + + private _handleClick(): void { + fireEvent(this, "hass-more-info", { entityId: this._config!.entity }); + } + + static get styles(): CSSResultGroup { + return [ + css` + ha-card { + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + cursor: pointer; + outline: none; + } + + .header { + display: flex; + padding: 8px 16px 0; + justify-content: space-between; + } + + .name { + color: var(--secondary-text-color); + line-height: 40px; + font-weight: 500; + font-size: 16px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .icon { + color: var(--state-icon-color, #44739e); + line-height: 40px; + } + + .info { + padding: 0px 16px 16px; + margin-top: -4px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + line-height: 28px; + } + + .value { + font-size: 28px; + margin-right: 4px; + } + + .measurement { + font-size: 18px; + color: var(--secondary-text-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-statistic-card": HuiStatisticCard; + } +} diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 8d7fe21412..b697e7c48f 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -1,4 +1,4 @@ -import { StatisticType } from "../../../data/recorder"; +import { Statistic, StatisticType } from "../../../data/recorder"; import { ActionConfig, LovelaceCardConfig } from "../../../data/lovelace"; import { FullCalendarView, TranslationDict } from "../../../types"; import { Condition } from "../common/validate-condition"; @@ -10,6 +10,7 @@ import { LovelaceRowConfig, } from "../entity-rows/types"; import { LovelaceHeaderFooterConfig } from "../header-footer/types"; +import { HaDurationData } from "../../../components/ha-duration-input"; export interface AlarmPanelCardConfig extends LovelaceCardConfig { entity: string; @@ -309,6 +310,18 @@ export interface StatisticsGraphCardConfig extends LovelaceCardConfig { chart_type?: "line" | "bar"; } +export interface StatisticCardConfig extends LovelaceCardConfig { + title?: string; + entities: Array; + period: { + fixed_period?: { start: string; end: string }; + calendar?: { period: string; offset: number }; + rolling_window?: { duration: HaDurationData; offset: HaDurationData }; + }; + stat_type: keyof Statistic; + theme?: string; +} + 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 115de4157b..98390dc9bf 100644 --- a/src/panels/lovelace/create-element/create-card-element.ts +++ b/src/panels/lovelace/create-element/create-card-element.ts @@ -79,6 +79,7 @@ const LAZY_LOAD_TYPES = { "shopping-list": () => import("../cards/hui-shopping-list-card"), starting: () => import("../cards/hui-starting-card"), "statistics-graph": () => import("../cards/hui-statistics-graph-card"), + statistic: () => import("../cards/hui-statistic-card"), "vertical-stack": () => import("../cards/hui-vertical-stack-card"), }; diff --git a/src/panels/lovelace/editor/config-elements/hui-statistic-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-statistic-card-editor.ts new file mode 100644 index 0000000000..f37f1a2b17 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-statistic-card-editor.ts @@ -0,0 +1,266 @@ +import type { HassEntity } from "home-assistant-js-websocket/dist/types"; +import { html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { any, assert, assign, object, optional, string } from "superstruct"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { computeDomain } from "../../../../common/entity/compute_domain"; +import { domainIcon } from "../../../../common/entity/domain_icon"; +import { LocalizeFunc } from "../../../../common/translations/localize"; +import { deepEqual } from "../../../../common/util/deep-equal"; +import "../../../../components/ha-form/ha-form"; +import type { SchemaUnion } from "../../../../components/ha-form/types"; +import { + getStatisticMetadata, + StatisticsMetaData, + statisticsMetaHasType, + StatisticType, +} from "../../../../data/recorder"; +import type { HomeAssistant } from "../../../../types"; +import type { StatisticCardConfig } from "../../cards/types"; +import { headerFooterConfigStructs } from "../../header-footer/structs"; +import type { LovelaceCardEditor } from "../../types"; +import { baseLovelaceCardConfig } from "../structs/base-card-struct"; + +const cardConfigStruct = assign( + baseLovelaceCardConfig, + object({ + entity: optional(string()), + name: optional(string()), + icon: optional(string()), + unit: optional(string()), + stat_type: optional(string()), + period: optional(any()), + theme: optional(string()), + footer: optional(headerFooterConfigStructs), + }) +); + +const stat_types = ["mean", "min", "max", "change"] as const; + +const statTypeMap: Record = { + mean: "mean", + min: "min", + max: "max", + change: "sum", +}; + +const periods = { + today: { calendar: { period: "day" } }, + yesterday: { calendar: { period: "day", offset: -1 } }, + this_week: { calendar: { period: "week" } }, + last_week: { calendar: { period: "week", offset: -1 } }, + this_month: { calendar: { period: "month" } }, + last_month: { calendar: { period: "month", offset: -1 } }, + this_year: { calendar: { period: "year" } }, + last_year: { calendar: { period: "year", offset: -1 } }, +} as const; + +@customElement("hui-statistic-card-editor") +export class HuiStatisticCardEditor + extends LitElement + implements LovelaceCardEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: StatisticCardConfig; + + @state() private _metadata?: StatisticsMetaData; + + public setConfig(config: StatisticCardConfig): void { + assert(config, cardConfigStruct); + this._config = config; + this._fetchMetadata(); + } + + firstUpdated() { + this._fetchMetadata().then(() => { + if (!this._config?.stat_type && this._config?.entity) { + fireEvent(this, "config-changed", { + config: { + ...this._config, + stat_type: this._metadata?.has_sum ? "change" : "mean", + }, + }); + } + }); + } + + private _data = memoizeOne((config: StatisticCardConfig) => { + if (!config || !config.period) { + return config; + } + for (const period of Object.values(periods)) { + if (deepEqual(period, config.period)) { + return { ...config, period }; + } + } + return config; + }); + + private _schema = memoizeOne( + ( + entity: string, + icon: string, + periodVal: any, + entityState: HassEntity, + localize: LocalizeFunc, + metadata?: StatisticsMetaData + ) => + [ + { name: "entity", required: true, selector: { statistic: {} } }, + { + name: "stat_type", + required: true, + selector: { + select: { + multiple: false, + options: stat_types.map((stat_type) => ({ + value: stat_type, + label: localize( + `ui.panel.lovelace.editor.card.statistic.stat_type_labels.${stat_type}` + ), + disabled: + !metadata || + !statisticsMetaHasType(metadata, statTypeMap[stat_type]), + })), + }, + }, + }, + { + name: "period", + required: true, + selector: Object.values(periods).includes(periodVal) + ? { + select: { + multiple: false, + options: Object.entries(periods).map( + ([periodKey, period]) => ({ + value: period, + label: + localize( + `ui.panel.lovelace.editor.card.statistic.periods.${periodKey}` + ) || periodKey, + }) + ), + }, + } + : { object: {} }, + }, + { + type: "grid", + name: "", + schema: [ + { name: "name", selector: { text: {} } }, + { + name: "icon", + selector: { + icon: { + placeholder: icon || entityState?.attributes.icon, + fallbackPath: + !icon && !entityState?.attributes.icon && entityState + ? domainIcon(computeDomain(entity), entityState) + : undefined, + }, + }, + }, + { name: "unit", selector: { text: {} } }, + { name: "theme", selector: { theme: {} } }, + ], + }, + ] as const + ); + + protected render(): TemplateResult { + if (!this.hass || !this._config) { + return html``; + } + + const entityState = this.hass.states[this._config.entity]; + + const data = this._data(this._config); + + const schema = this._schema( + this._config.entity, + this._config.icon, + data.period, + entityState, + this.hass.localize, + this._metadata + ); + + return html` + + `; + } + + private async _fetchMetadata() { + if (!this.hass || !this._config) { + return; + } + this._metadata = ( + await getStatisticMetadata(this.hass, [this._config.entity]) + )[0]; + } + + private async _valueChanged(ev: CustomEvent) { + const config = ev.detail.value as StatisticCardConfig; + Object.keys(config).forEach((k) => config[k] === "" && delete config[k]); + if ( + config.stat_type && + config.entity && + config.entity !== this._metadata?.statistic_id + ) { + const metadata = ( + await getStatisticMetadata(this.hass!, [config.entity]) + )?.[0]; + if (metadata && !metadata.has_sum && config.stat_type === "change") { + config.stat_type = "mean"; + } + if (metadata && !metadata.has_mean && config.stat_type !== "change") { + config.stat_type = "change"; + } + } + if (!config.stat_type && config.entity) { + const metadata = ( + await getStatisticMetadata(this.hass!, [config.entity]) + )?.[0]; + config.stat_type = metadata?.has_sum ? "change" : "mean"; + } + fireEvent(this, "config-changed", { config }); + } + + private _computeLabelCallback = ( + schema: SchemaUnion> + ) => { + if (schema.name === "period") { + return this.hass!.localize( + "ui.panel.lovelace.editor.card.statistic.period" + ); + } + + if (schema.name === "theme") { + return `${this.hass!.localize( + "ui.panel.lovelace.editor.card.generic.theme" + )} (${this.hass!.localize( + "ui.panel.lovelace.editor.card.config.optional" + )})`; + } + + return this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ); + }; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-statistic-card-editor": HuiStatisticCardEditor; + } +} diff --git a/src/panels/lovelace/editor/lovelace-cards.ts b/src/panels/lovelace/editor/lovelace-cards.ts index 3f3addb715..9dbefa4506 100644 --- a/src/panels/lovelace/editor/lovelace-cards.ts +++ b/src/panels/lovelace/editor/lovelace-cards.ts @@ -37,6 +37,10 @@ export const coreCards: Card[] = [ type: "statistics-graph", showElement: false, }, + { + type: "statistic", + showElement: true, + }, { type: "humidifier", showElement: true, diff --git a/src/translations/en.json b/src/translations/en.json index 5aa0c39ff4..5f85ffaa98 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -4065,6 +4065,28 @@ "pick_statistic": "Add a statistic", "picked_statistic": "Statistic" }, + "statistic": { + "name": "Statistic", + "description": "The Statistic card allows you to display a statistical value of an entity of a certain period.", + "period": "Period", + "stat_types": "Show stat", + "stat_type_labels": { + "mean": "Mean", + "min": "Min", + "max": "Max", + "change": "Change" + }, + "periods": { + "today": "Today", + "yesterday": "Yesterday", + "this_week": "This week", + "last_week": "Last week", + "this_month": "This month", + "last_month": "Last month", + "this_year": "This year", + "last_year": "Last year" + } + }, "horizontal-stack": { "name": "Horizontal Stack", "description": "The Horizontal Stack card allows you to stack together multiple cards, so they always sit next to each other in the space of one column."