1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-04-02 08:33:31 +01:00

Add power, water and gas current flow rate tile cards (#29788)

This commit is contained in:
Petar Petrov
2026-02-24 16:01:32 +01:00
committed by GitHub
parent 1b60e6e04e
commit bcfaa67eba
8 changed files with 655 additions and 12 deletions

View File

@@ -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<string, number> = {
"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

View File

@@ -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<LovelaceViewConfig> {
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;
}
}

View File

@@ -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<string>();
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> | 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`
<ha-card>
<ha-tile-container .interactive=${false}>
<ha-tile-icon slot="icon" data-domain="sensor" data-state="active">
<ha-svg-icon slot="icon" .path=${mdiFire}></ha-svg-icon>
</ha-tile-icon>
<ha-tile-info slot="info">
<span slot="primary" class="primary">${name}</span>
<span slot="secondary" class="secondary">${displayValue}</span>
</ha-tile-info>
</ha-tile-container>
</ha-card>
`;
}
static styles = [
tileCardStyle,
css`
:host {
--tile-color: var(--energy-gas-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"hui-gas-total-card": HuiGasTotalCard;
}
}

View File

@@ -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<string>();
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> | 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`
<ha-card>
<ha-tile-container .interactive=${false}>
<ha-tile-icon slot="icon" data-domain="sensor" data-state="active">
<ha-svg-icon
slot="icon"
.path=${mdiHomeLightningBolt}
></ha-svg-icon>
</ha-tile-icon>
<ha-tile-info slot="info">
<span slot="primary" class="primary">${name}</span>
<span slot="secondary" class="secondary">${displayValue}</span>
</ha-tile-info>
</ha-tile-container>
</ha-card>
`;
}
static styles = [
tileCardStyle,
css`
:host {
--tile-color: var(--primary-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"hui-power-total-card": HuiPowerTotalCard;
}
}

View File

@@ -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<string>();
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> | 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`
<ha-card>
<ha-tile-container .interactive=${false}>
<ha-tile-icon slot="icon" data-domain="sensor" data-state="active">
<ha-svg-icon slot="icon" .path=${mdiWater}></ha-svg-icon>
</ha-tile-icon>
<ha-tile-info slot="info">
<span slot="primary" class="primary">${name}</span>
<span slot="secondary" class="secondary">${displayValue}</span>
</ha-tile-info>
</ha-tile-container>
</ha-card>
`;
}
static styles = [
tileCardStyle,
css`
:host {
--tile-color: var(--energy-water-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"hui-water-total-card": HuiWaterTotalCard;
}
}

View File

@@ -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;

View File

@@ -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"),

View File

@@ -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",