From bcfaa67eba6399923f3c99796b79dde1e2cfbc7d Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 24 Feb 2026 16:01:32 +0100 Subject: [PATCH] Add power, water and gas current flow rate tile cards (#29788) --- src/data/energy.ts | 74 ++++++++ .../energy/strategies/power-view-strategy.ts | 89 +++++++-- .../cards/energy/hui-gas-total-card.ts | 153 +++++++++++++++ .../cards/energy/hui-power-total-card.ts | 175 ++++++++++++++++++ .../cards/energy/hui-water-total-card.ts | 153 +++++++++++++++ src/panels/lovelace/cards/types.ts | 15 ++ .../create-element/create-card-element.ts | 3 + src/translations/en.json | 5 +- 8 files changed, 655 insertions(+), 12 deletions(-) create mode 100644 src/panels/lovelace/cards/energy/hui-gas-total-card.ts create mode 100644 src/panels/lovelace/cards/energy/hui-power-total-card.ts create mode 100644 src/panels/lovelace/cards/energy/hui-water-total-card.ts diff --git a/src/data/energy.ts b/src/data/energy.ts index e9976fab43..3cafae12a6 100644 --- a/src/data/energy.ts +++ b/src/data/energy.ts @@ -1401,6 +1401,80 @@ export const calculateSolarConsumedGauge = ( return undefined; }; +/** + * Conversion factors from each flow rate unit to L/min. + * All HA-supported UnitOfVolumeFlowRate values are covered. + * + * m³/h → 1000/60 = 16.6667 L/min + * m³/min → 1000 L/min + * m³/s → 60000 L/min + * ft³/min→ 28.3168 L/min + * L/h → 1/60 L/min + * L/min → 1 L/min + * L/s → 60 L/min + * gal/h → 3.78541/60 L/min + * gal/min→ 3.78541 L/min + * gal/d → 3.78541/1440 L/min + * mL/s → 0.06 L/min + */ + +/** Exact number of liters in one US gallon */ +const LITERS_PER_GALLON = 3.785411784; + +const FLOW_RATE_TO_LMIN: Record = { + "m³/h": 1000 / 60, + "m³/min": 1000, + "m³/s": 60000, + "ft³/min": 28.316846592, + "L/h": 1 / 60, + "L/min": 1, + "L/s": 60, + "gal/h": LITERS_PER_GALLON / 60, + "gal/min": LITERS_PER_GALLON, + "gal/d": LITERS_PER_GALLON / 1440, + "mL/s": 60 / 1000, +}; + +/** + * Get current flow rate from an entity state, converted to L/min. + * @returns Flow rate in L/min, or undefined if unavailable/invalid. + */ +export const getFlowRateFromState = ( + stateObj?: HassEntity +): number | undefined => { + if (!stateObj) { + return undefined; + } + const value = parseFloat(stateObj.state); + if (isNaN(value)) { + return undefined; + } + const unit = stateObj.attributes.unit_of_measurement; + const factor = unit ? FLOW_RATE_TO_LMIN[unit] : undefined; + if (factor === undefined) { + // Unknown unit – return raw value as-is (best effort) + return value; + } + return value * factor; +}; + +/** + * Format a flow rate value (in L/min) to a human-readable string using + * the preferred unit system: metric → L/min, imperial → gal/min. + */ +export const formatFlowRateShort = ( + hassLocale: HomeAssistant["locale"], + lengthUnitSystem: string, + litersPerMin: number +): string => { + const isMetric = lengthUnitSystem === "km"; + if (isMetric) { + return `${formatNumber(litersPerMin, hassLocale, { maximumFractionDigits: 1 })} L/min`; + } + const galPerMin = litersPerMin / LITERS_PER_GALLON; + return `${formatNumber(galPerMin, hassLocale, { maximumFractionDigits: 1 })} gal/min`; +}; + /** * Get current power value from entity state, normalized to watts (W) * @param stateObj - The entity state object to get power value from diff --git a/src/panels/energy/strategies/power-view-strategy.ts b/src/panels/energy/strategies/power-view-strategy.ts index de892be134..83e5f41793 100644 --- a/src/panels/energy/strategies/power-view-strategy.ts +++ b/src/panels/energy/strategies/power-view-strategy.ts @@ -1,12 +1,17 @@ import { ReactiveElement } from "lit"; import { customElement } from "lit/decorators"; import { getEnergyDataCollection } from "../../../data/energy"; -import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section"; import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy"; import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; import type { HomeAssistant } from "../../../types"; import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy"; import { shouldShowFloorsAndAreas } from "./show-floors-and-areas"; +import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section"; +import { + LARGE_SCREEN_CONDITION, + SMALL_SCREEN_CONDITION, +} from "../../lovelace/strategies/helpers/screen-conditions"; +import type { LovelaceCardConfig } from "../../../data/lovelace/config/card"; @customElement("power-view-strategy") export class PowerViewStrategy extends ReactiveElement { @@ -14,11 +19,6 @@ export class PowerViewStrategy extends ReactiveElement { _config: LovelaceStrategyConfig, hass: HomeAssistant ): Promise { - const view: LovelaceViewConfig = { - type: "sections", - sections: [{ type: "grid", cards: [] }], - }; - const collectionKey = _config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY; @@ -39,16 +39,50 @@ export class PowerViewStrategy extends ReactiveElement { const hasPowerDevices = prefs?.device_consumption.some( (device) => device.stat_rate ); + const hasWaterSources = prefs?.energy_sources.some( + (source) => source.type === "water" && source.stat_rate + ); + const hasGasSources = prefs?.energy_sources.some( + (source) => source.type === "gas" && source.stat_rate + ); + + const tileSection: LovelaceSectionConfig = { + type: "grid", + cards: [], + column_span: 2, + }; + const chartsSection: LovelaceSectionConfig = { + type: "grid", + cards: [], + column_span: 2, + }; + const tiles: LovelaceCardConfig[] = []; + + const view: LovelaceViewConfig = { + type: "sections", + sections: [tileSection, chartsSection], + max_columns: 2, + }; // No power sources configured - if (!prefs || (!hasPowerSources && !hasPowerDevices)) { + if ( + !prefs || + (!hasPowerSources && + !hasPowerDevices && + !hasWaterSources && + !hasGasSources) + ) { return view; } - const section = view.sections![0] as LovelaceSectionConfig; - if (hasPowerSources) { - section.cards!.push({ + const card = { + type: "power-total", + collection_key: collectionKey, + }; + tiles.push(card); + + chartsSection.cards!.push({ title: hass.localize("ui.panel.energy.cards.power_sources_graph_title"), type: "power-sources-graph", collection_key: collectionKey, @@ -58,13 +92,29 @@ export class PowerViewStrategy extends ReactiveElement { }); } + if (hasGasSources) { + const card = { + type: "gas-total", + collection_key: collectionKey, + }; + tiles.push({ ...card }); + } + + if (hasWaterSources) { + const card = { + type: "water-total", + collection_key: collectionKey, + }; + tiles.push({ ...card }); + } + if (hasPowerDevices) { const showFloorsAndAreas = shouldShowFloorsAndAreas( prefs.device_consumption, hass, (d) => d.stat_rate ); - section.cards!.push({ + chartsSection.cards!.push({ title: hass.localize("ui.panel.energy.cards.power_sankey_title"), type: "power-sankey", collection_key: collectionKey, @@ -76,6 +126,23 @@ export class PowerViewStrategy extends ReactiveElement { }); } + tiles.forEach((card) => { + tileSection.cards!.push({ + ...card, + grid_options: { columns: 24 / tiles.length }, + }); + }); + + if (tiles.length > 2) { + // On small screens with 3 tiles, show them in 1 column + tileSection.visibility = [LARGE_SCREEN_CONDITION]; + view.sections!.unshift({ + type: "grid", + cards: tiles, + visibility: [SMALL_SCREEN_CONDITION], + }); + } + return view; } } diff --git a/src/panels/lovelace/cards/energy/hui-gas-total-card.ts b/src/panels/lovelace/cards/energy/hui-gas-total-card.ts new file mode 100644 index 0000000000..cca6c4b737 --- /dev/null +++ b/src/panels/lovelace/cards/energy/hui-gas-total-card.ts @@ -0,0 +1,153 @@ +import { mdiFire } from "@mdi/js"; +import type { UnsubscribeFunc } from "home-assistant-js-websocket"; +import type { PropertyValues } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "../../../../components/ha-card"; +import "../../../../components/ha-svg-icon"; +import "../../../../components/tile/ha-tile-container"; +import "../../../../components/tile/ha-tile-icon"; +import "../../../../components/tile/ha-tile-info"; +import type { EnergyData, EnergyPreferences } from "../../../../data/energy"; +import { + formatFlowRateShort, + getEnergyDataCollection, + getFlowRateFromState, +} from "../../../../data/energy"; +import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; +import type { HomeAssistant } from "../../../../types"; +import type { LovelaceCard, LovelaceGridOptions } from "../../types"; +import { tileCardStyle } from "../tile/tile-card-style"; +import type { GasTotalCardConfig } from "../types"; + +@customElement("hui-gas-total-card") +export class HuiGasTotalCard + extends SubscribeMixin(LitElement) + implements LovelaceCard +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _config?: GasTotalCardConfig; + + @state() private _data?: EnergyData; + + private _entities = new Set(); + + protected hassSubscribeRequiredHostProps = ["_config"]; + + public setConfig(config: GasTotalCardConfig): void { + this._config = config; + } + + public hassSubscribe(): UnsubscribeFunc[] { + return [ + getEnergyDataCollection(this.hass, { + key: this._config?.collection_key, + }).subscribe((data) => { + this._data = data; + }), + ]; + } + + public getCardSize(): Promise | number { + return 1; + } + + getGridOptions(): LovelaceGridOptions { + return { + columns: 12, + min_columns: 6, + rows: 1, + min_rows: 1, + }; + } + + protected shouldUpdate(changedProps: PropertyValues): boolean { + if (changedProps.has("_config") || changedProps.has("_data")) { + return true; + } + + // Check if any of the tracked entity states have changed + if (changedProps.has("hass")) { + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + if (!oldHass || !this._entities.size) { + return true; + } + + // Only update if one of our tracked entities changed + for (const entityId of this._entities) { + if (oldHass.states[entityId] !== this.hass.states[entityId]) { + return true; + } + } + } + + return false; + } + + private _getCurrentFlowRate(entityId: string): number { + this._entities.add(entityId); + return getFlowRateFromState(this.hass.states[entityId]) ?? 0; + } + + private _computeTotalFlowRate(prefs: EnergyPreferences): number { + this._entities.clear(); + + let totalFlow = 0; + + prefs.energy_sources.forEach((source) => { + if (source.type === "gas" && source.stat_rate) { + const value = this._getCurrentFlowRate(source.stat_rate); + if (value > 0) totalFlow += value; + } + }); + + return Math.max(0, totalFlow); + } + + protected render() { + if (!this._config || !this._data) { + return nothing; + } + + const flowRate = this._computeTotalFlowRate(this._data.prefs); + const displayValue = formatFlowRateShort( + this.hass.locale, + this.hass.config.unit_system.length, + flowRate + ); + + const name = + this._config.title || + this.hass.localize("ui.panel.lovelace.cards.energy.gas_total_title"); + + return html` + + + + + + + ${name} + ${displayValue} + + + + `; + } + + static styles = [ + tileCardStyle, + css` + :host { + --tile-color: var(--energy-gas-color); + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-gas-total-card": HuiGasTotalCard; + } +} diff --git a/src/panels/lovelace/cards/energy/hui-power-total-card.ts b/src/panels/lovelace/cards/energy/hui-power-total-card.ts new file mode 100644 index 0000000000..d94161cd08 --- /dev/null +++ b/src/panels/lovelace/cards/energy/hui-power-total-card.ts @@ -0,0 +1,175 @@ +import { mdiHomeLightningBolt } from "@mdi/js"; +import type { UnsubscribeFunc } from "home-assistant-js-websocket"; +import type { PropertyValues } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { formatNumber } from "../../../../common/number/format_number"; +import "../../../../components/ha-card"; +import "../../../../components/ha-svg-icon"; +import "../../../../components/tile/ha-tile-container"; +import "../../../../components/tile/ha-tile-icon"; +import "../../../../components/tile/ha-tile-info"; +import type { EnergyData, EnergyPreferences } from "../../../../data/energy"; +import { + getEnergyDataCollection, + getPowerFromState, +} from "../../../../data/energy"; +import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; +import type { HomeAssistant } from "../../../../types"; +import type { LovelaceCard, LovelaceGridOptions } from "../../types"; +import { tileCardStyle } from "../tile/tile-card-style"; +import type { PowerTotalCardConfig } from "../types"; + +@customElement("hui-power-total-card") +export class HuiPowerTotalCard + extends SubscribeMixin(LitElement) + implements LovelaceCard +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _config?: PowerTotalCardConfig; + + @state() private _data?: EnergyData; + + private _entities = new Set(); + + protected hassSubscribeRequiredHostProps = ["_config"]; + + public setConfig(config: PowerTotalCardConfig): void { + this._config = config; + } + + public hassSubscribe(): UnsubscribeFunc[] { + return [ + getEnergyDataCollection(this.hass, { + key: this._config?.collection_key, + }).subscribe((data) => { + this._data = data; + }), + ]; + } + + public getCardSize(): Promise | number { + return 1; + } + + getGridOptions(): LovelaceGridOptions { + return { + columns: 12, + min_columns: 6, + rows: 1, + min_rows: 1, + }; + } + + protected shouldUpdate(changedProps: PropertyValues): boolean { + if (changedProps.has("_config") || changedProps.has("_data")) { + return true; + } + + // Check if any of the tracked entity states have changed + if (changedProps.has("hass")) { + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + if (!oldHass || !this._entities.size) { + return true; + } + + // Only update if one of our tracked entities changed + for (const entityId of this._entities) { + if (oldHass.states[entityId] !== this.hass.states[entityId]) { + return true; + } + } + } + + return false; + } + + private _getCurrentPower(entityId: string): number { + this._entities.add(entityId); + return getPowerFromState(this.hass.states[entityId]) ?? 0; + } + + private _computeTotalPower(prefs: EnergyPreferences): number { + this._entities.clear(); + + let solar = 0; + let from_grid = 0; + let to_grid = 0; + let from_battery = 0; + let to_battery = 0; + + prefs.energy_sources.forEach((source) => { + if (source.type === "solar" && source.stat_rate) { + const value = this._getCurrentPower(source.stat_rate); + if (value > 0) solar += value; + } else if (source.type === "grid" && source.stat_rate) { + const value = this._getCurrentPower(source.stat_rate); + if (value > 0) from_grid += value; + else if (value < 0) to_grid += Math.abs(value); + } else if (source.type === "battery" && source.stat_rate) { + const value = this._getCurrentPower(source.stat_rate); + if (value > 0) from_battery += value; + else if (value < 0) to_battery += Math.abs(value); + } + }); + + const used_total = from_grid + solar + from_battery - to_grid - to_battery; + return Math.max(0, used_total); + } + + protected render() { + if (!this._config || !this._data) { + return nothing; + } + + const power = this._computeTotalPower(this._data.prefs); + + let displayValue = ""; + if (power >= 1000) { + displayValue = `${formatNumber(power / 1000, this.hass.locale, { + maximumFractionDigits: 2, + })} kW`; + } else { + displayValue = `${formatNumber(power, this.hass.locale, { + maximumFractionDigits: 0, + })} W`; + } + + const name = + this._config.title || + this.hass.localize("ui.panel.lovelace.cards.energy.power_total_title"); + + return html` + + + + + + + ${name} + ${displayValue} + + + + `; + } + + static styles = [ + tileCardStyle, + css` + :host { + --tile-color: var(--primary-color); + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-power-total-card": HuiPowerTotalCard; + } +} diff --git a/src/panels/lovelace/cards/energy/hui-water-total-card.ts b/src/panels/lovelace/cards/energy/hui-water-total-card.ts new file mode 100644 index 0000000000..23f9c972fb --- /dev/null +++ b/src/panels/lovelace/cards/energy/hui-water-total-card.ts @@ -0,0 +1,153 @@ +import { mdiWater } from "@mdi/js"; +import type { UnsubscribeFunc } from "home-assistant-js-websocket"; +import type { PropertyValues } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "../../../../components/ha-card"; +import "../../../../components/ha-svg-icon"; +import "../../../../components/tile/ha-tile-container"; +import "../../../../components/tile/ha-tile-icon"; +import "../../../../components/tile/ha-tile-info"; +import type { EnergyData, EnergyPreferences } from "../../../../data/energy"; +import { + formatFlowRateShort, + getEnergyDataCollection, + getFlowRateFromState, +} from "../../../../data/energy"; +import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; +import type { HomeAssistant } from "../../../../types"; +import type { LovelaceCard, LovelaceGridOptions } from "../../types"; +import { tileCardStyle } from "../tile/tile-card-style"; +import type { WaterTotalCardConfig } from "../types"; + +@customElement("hui-water-total-card") +export class HuiWaterTotalCard + extends SubscribeMixin(LitElement) + implements LovelaceCard +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _config?: WaterTotalCardConfig; + + @state() private _data?: EnergyData; + + private _entities = new Set(); + + protected hassSubscribeRequiredHostProps = ["_config"]; + + public setConfig(config: WaterTotalCardConfig): void { + this._config = config; + } + + public hassSubscribe(): UnsubscribeFunc[] { + return [ + getEnergyDataCollection(this.hass, { + key: this._config?.collection_key, + }).subscribe((data) => { + this._data = data; + }), + ]; + } + + public getCardSize(): Promise | number { + return 1; + } + + getGridOptions(): LovelaceGridOptions { + return { + columns: 12, + min_columns: 6, + rows: 1, + min_rows: 1, + }; + } + + protected shouldUpdate(changedProps: PropertyValues): boolean { + if (changedProps.has("_config") || changedProps.has("_data")) { + return true; + } + + // Check if any of the tracked entity states have changed + if (changedProps.has("hass")) { + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + if (!oldHass || !this._entities.size) { + return true; + } + + // Only update if one of our tracked entities changed + for (const entityId of this._entities) { + if (oldHass.states[entityId] !== this.hass.states[entityId]) { + return true; + } + } + } + + return false; + } + + private _getCurrentFlowRate(entityId: string): number { + this._entities.add(entityId); + return getFlowRateFromState(this.hass.states[entityId]) ?? 0; + } + + private _computeTotalFlowRate(prefs: EnergyPreferences): number { + this._entities.clear(); + + let totalFlow = 0; + + prefs.energy_sources.forEach((source) => { + if (source.type === "water" && source.stat_rate) { + const value = this._getCurrentFlowRate(source.stat_rate); + if (value > 0) totalFlow += value; + } + }); + + return Math.max(0, totalFlow); + } + + protected render() { + if (!this._config || !this._data) { + return nothing; + } + + const flowRate = this._computeTotalFlowRate(this._data.prefs); + const displayValue = formatFlowRateShort( + this.hass.locale, + this.hass.config.unit_system.length, + flowRate + ); + + const name = + this._config.title || + this.hass.localize("ui.panel.lovelace.cards.energy.water_total_title"); + + return html` + + + + + + + ${name} + ${displayValue} + + + + `; + } + + static styles = [ + tileCardStyle, + css` + :host { + --tile-color: var(--energy-water-color); + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-water-total-card": HuiWaterTotalCard; + } +} diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 09df5027b0..ae001db62d 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -257,6 +257,21 @@ export interface PowerSourcesGraphCardConfig extends EnergyCardBaseConfig { show_legend?: boolean; } +export interface PowerTotalCardConfig extends EnergyCardBaseConfig { + type: "power-total"; + title?: string; +} + +export interface WaterTotalCardConfig extends EnergyCardBaseConfig { + type: "water-total"; + title?: string; +} + +export interface GasTotalCardConfig extends EnergyCardBaseConfig { + type: "gas-total"; + title?: string; +} + export interface PowerSankeyCardConfig extends EnergyCardBaseConfig { type: "power-sankey"; title?: string; diff --git a/src/panels/lovelace/create-element/create-card-element.ts b/src/panels/lovelace/create-element/create-card-element.ts index 2614ec6cf6..ea5ffb91bc 100644 --- a/src/panels/lovelace/create-element/create-card-element.ts +++ b/src/panels/lovelace/create-element/create-card-element.ts @@ -69,6 +69,9 @@ const LAZY_LOAD_TYPES = { "water-sankey": () => import("../cards/water/hui-water-sankey-card"), "power-sources-graph": () => import("../cards/energy/hui-power-sources-graph-card"), + "power-total": () => import("../cards/energy/hui-power-total-card"), + "water-total": () => import("../cards/energy/hui-water-total-card"), + "gas-total": () => import("../cards/energy/hui-gas-total-card"), "power-sankey": () => import("../cards/energy/hui-power-sankey-card"), "entity-filter": () => import("../cards/hui-entity-filter-card"), error: () => import("../cards/hui-error-card"), diff --git a/src/translations/en.json b/src/translations/en.json index 41fe01d2c6..13ddce0691 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -8260,7 +8260,10 @@ "info": "You are comparing the period {start} with the period {end}", "compare_previous_year": "Compare previous year", "compare_previous_period": "Compare previous period" - } + }, + "power_total_title": "Power usage", + "water_total_title": "[%key:ui::panel::config::energy::water::dialog::water_flow_rate%]", + "gas_total_title": "[%key:ui::panel::config::energy::gas::dialog::gas_flow_rate%]" }, "distribution": { "no_entities": "No entities specified",