From 967f4a227c4379f71da2350bfa91c67ecd0c8fac Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 25 Nov 2025 18:18:13 +0200 Subject: [PATCH] Add support for downstream water meters in energy dashboard (#27833) --- demo/src/stubs/energy.ts | 1 + src/data/energy.ts | 7 + .../ha-energy-device-settings-water.ts | 257 +++++++++++++++++ .../dialog-energy-device-settings-water.ts | 268 ++++++++++++++++++ .../energy/dialogs/show-dialogs-energy.ts | 18 ++ src/panels/config/energy/ha-config-energy.ts | 9 + .../energy/cards/energy-setup-wizard-card.ts | 1 + src/translations/en.json | 16 ++ 8 files changed, 577 insertions(+) create mode 100644 src/panels/config/energy/components/ha-energy-device-settings-water.ts create mode 100644 src/panels/config/energy/dialogs/dialog-energy-device-settings-water.ts diff --git a/demo/src/stubs/energy.ts b/demo/src/stubs/energy.ts index 6df7bde7fb..c038828c5c 100644 --- a/demo/src/stubs/energy.ts +++ b/demo/src/stubs/energy.ts @@ -84,6 +84,7 @@ export const mockEnergy = (hass: MockHomeAssistant) => { stat_consumption: "sensor.energy_boiler", }, ], + device_consumption_water: [], }) ); hass.mockWS( diff --git a/src/data/energy.ts b/src/data/energy.ts index 337be0633f..be73dfa3ae 100644 --- a/src/data/energy.ts +++ b/src/data/energy.ts @@ -200,6 +200,7 @@ export type EnergySource = export interface EnergyPreferences { energy_sources: EnergySource[]; device_consumption: DeviceConsumptionEnergyPreference[]; + device_consumption_water: DeviceConsumptionEnergyPreference[]; } export interface EnergyInfo { @@ -216,6 +217,7 @@ export interface EnergyValidationIssue { export interface EnergyPreferencesValidation { energy_sources: EnergyValidationIssue[][]; device_consumption: EnergyValidationIssue[][]; + device_consumption_water: EnergyValidationIssue[][]; } export const getEnergyInfo = (hass: HomeAssistant) => @@ -356,6 +358,11 @@ export const getReferencedStatisticIds = ( if (!(includeTypes && !includeTypes.includes("device"))) { statIDs.push(...prefs.device_consumption.map((d) => d.stat_consumption)); } + if (!(includeTypes && !includeTypes.includes("water"))) { + statIDs.push( + ...prefs.device_consumption_water.map((d) => d.stat_consumption) + ); + } return statIDs; }; diff --git a/src/panels/config/energy/components/ha-energy-device-settings-water.ts b/src/panels/config/energy/components/ha-energy-device-settings-water.ts new file mode 100644 index 0000000000..7ca619264a --- /dev/null +++ b/src/panels/config/energy/components/ha-energy-device-settings-water.ts @@ -0,0 +1,257 @@ +import { + mdiDelete, + mdiWater, + mdiDragHorizontalVariant, + mdiPencil, + mdiPlus, +} from "@mdi/js"; +import type { CSSResultGroup, TemplateResult } from "lit"; +import { css, html, LitElement } from "lit"; +import { repeat } from "lit/directives/repeat"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-card"; +import "../../../../components/ha-button"; +import "../../../../components/ha-icon-button"; +import "../../../../components/ha-sortable"; +import "../../../../components/ha-svg-icon"; +import type { + DeviceConsumptionEnergyPreference, + EnergyPreferences, + EnergyPreferencesValidation, +} from "../../../../data/energy"; +import { saveEnergyPreferences } from "../../../../data/energy"; +import type { StatisticsMetaData } from "../../../../data/recorder"; +import { getStatisticLabel } from "../../../../data/recorder"; +import { + showAlertDialog, + showConfirmationDialog, +} from "../../../../dialogs/generic/show-dialog-box"; +import { haStyle } from "../../../../resources/styles"; +import type { HomeAssistant } from "../../../../types"; +import { documentationUrl } from "../../../../util/documentation-url"; +import { showEnergySettingsDeviceWaterDialog } from "../dialogs/show-dialogs-energy"; +import "./ha-energy-validation-result"; +import { energyCardStyles } from "./styles"; + +@customElement("ha-energy-device-settings-water") +export class EnergyDeviceSettingsWater extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) + public preferences!: EnergyPreferences; + + @property({ attribute: false }) + public statsMetadata?: Record; + + @property({ attribute: false }) + public validationResult?: EnergyPreferencesValidation; + + protected render(): TemplateResult { + return html` + +

+ + ${this.hass.localize( + "ui.panel.config.energy.device_consumption_water.title" + )} +

+ +
+

+ ${this.hass.localize( + "ui.panel.config.energy.device_consumption_water.sub" + )} + ${this.hass.localize( + "ui.panel.config.energy.device_consumption_water.learn_more" + )} +

+ ${this.validationResult?.device_consumption_water.map( + (result) => html` + + ` + )} +

+ ${this.hass.localize( + "ui.panel.config.energy.device_consumption_water.devices" + )} +

+ +
+ ${repeat( + this.preferences.device_consumption_water, + (device) => device.stat_consumption, + (device) => html` +
+
+ +
+ ${device.name || + getStatisticLabel( + this.hass, + device.stat_consumption, + this.statsMetadata?.[device.stat_consumption] + )} + + +
+ ` + )} +
+
+
+ + + ${this.hass.localize( + "ui.panel.config.energy.device_consumption_water.add_device" + )} +
+
+
+ `; + } + + private _itemMoved(ev: CustomEvent): void { + ev.stopPropagation(); + const { oldIndex, newIndex } = ev.detail; + const devices = this.preferences.device_consumption_water.concat(); + const device = devices.splice(oldIndex, 1)[0]; + devices.splice(newIndex, 0, device); + + const newPrefs = { + ...this.preferences, + device_consumption_water: devices, + }; + fireEvent(this, "value-changed", { value: newPrefs }); + this._savePreferences(newPrefs); + } + + private _editDevice(ev) { + const origDevice: DeviceConsumptionEnergyPreference = + ev.currentTarget.closest(".row").device; + showEnergySettingsDeviceWaterDialog(this, { + statsMetadata: this.statsMetadata, + device: { ...origDevice }, + device_consumptions: this.preferences + .device_consumption_water as DeviceConsumptionEnergyPreference[], + saveCallback: async (newDevice) => { + const newPrefs = { + ...this.preferences, + device_consumption_water: + this.preferences.device_consumption_water.map((d) => + d === origDevice ? newDevice : d + ), + }; + this._sanitizeParents(newPrefs); + await this._savePreferences(newPrefs); + }, + }); + } + + private _addDevice() { + showEnergySettingsDeviceWaterDialog(this, { + statsMetadata: this.statsMetadata, + device_consumptions: this.preferences + .device_consumption_water as DeviceConsumptionEnergyPreference[], + saveCallback: async (device) => { + await this._savePreferences({ + ...this.preferences, + device_consumption_water: + this.preferences.device_consumption_water.concat(device), + }); + }, + }); + } + + private _sanitizeParents(prefs: EnergyPreferences) { + const statIds = prefs.device_consumption_water.map( + (d) => d.stat_consumption + ); + prefs.device_consumption_water.forEach((d) => { + if (d.included_in_stat && !statIds.includes(d.included_in_stat)) { + delete d.included_in_stat; + } + }); + } + + private async _deleteDevice(ev) { + const deviceToDelete: DeviceConsumptionEnergyPreference = + ev.currentTarget.device; + + if ( + !(await showConfirmationDialog(this, { + title: this.hass.localize("ui.panel.config.energy.delete_source"), + })) + ) { + return; + } + + try { + const newPrefs = { + ...this.preferences, + device_consumption_water: + this.preferences.device_consumption_water.filter( + (device) => device !== deviceToDelete + ), + }; + this._sanitizeParents(newPrefs); + await this._savePreferences(newPrefs); + } catch (err: any) { + showAlertDialog(this, { title: `Failed to save config: ${err.message}` }); + } + } + + private async _savePreferences(preferences: EnergyPreferences) { + const result = await saveEnergyPreferences(this.hass, preferences); + fireEvent(this, "value-changed", { value: result }); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + energyCardStyles, + css` + .handle { + cursor: move; /* fallback if grab cursor is unsupported */ + cursor: grab; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-energy-device-settings-water": EnergyDeviceSettingsWater; + } +} diff --git a/src/panels/config/energy/dialogs/dialog-energy-device-settings-water.ts b/src/panels/config/energy/dialogs/dialog-energy-device-settings-water.ts new file mode 100644 index 0000000000..7e2d625469 --- /dev/null +++ b/src/panels/config/energy/dialogs/dialog-energy-device-settings-water.ts @@ -0,0 +1,268 @@ +import { mdiWater } from "@mdi/js"; +import type { CSSResultGroup } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { stopPropagation } from "../../../../common/dom/stop_propagation"; +import "../../../../components/entity/ha-entity-picker"; +import "../../../../components/entity/ha-statistic-picker"; +import "../../../../components/ha-dialog"; +import "../../../../components/ha-radio"; +import "../../../../components/ha-button"; +import "../../../../components/ha-select"; +import "../../../../components/ha-list-item"; +import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy"; +import { energyStatisticHelpUrl } from "../../../../data/energy"; +import { getStatisticLabel } from "../../../../data/recorder"; +import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor"; +import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; +import { haStyleDialog } from "../../../../resources/styles"; +import type { HomeAssistant } from "../../../../types"; +import type { EnergySettingsDeviceWaterDialogParams } from "./show-dialogs-energy"; + +const volumeUnitClasses = ["volume"]; + +@customElement("dialog-energy-device-settings-water") +export class DialogEnergyDeviceSettingsWater + extends LitElement + implements HassDialog +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: EnergySettingsDeviceWaterDialogParams; + + @state() private _device?: DeviceConsumptionEnergyPreference; + + @state() private _volume_units?: string[]; + + @state() private _error?: string; + + private _excludeList?: string[]; + + private _possibleParents: DeviceConsumptionEnergyPreference[] = []; + + public async showDialog( + params: EnergySettingsDeviceWaterDialogParams + ): Promise { + this._params = params; + this._device = this._params.device; + this._computePossibleParents(); + this._volume_units = ( + await getSensorDeviceClassConvertibleUnits(this.hass, "water") + ).units; + this._excludeList = this._params.device_consumptions + .map((entry) => entry.stat_consumption) + .filter((id) => id !== this._device?.stat_consumption); + } + + private _computePossibleParents() { + if (!this._device || !this._params) { + this._possibleParents = []; + return; + } + const children: string[] = []; + const devices = this._params.device_consumptions; + function getChildren(stat) { + devices.forEach((d) => { + if (d.included_in_stat === stat) { + children.push(d.stat_consumption); + getChildren(d.stat_consumption); + } + }); + } + getChildren(this._device.stat_consumption); + this._possibleParents = this._params.device_consumptions.filter( + (d) => + d.stat_consumption !== this._device!.stat_consumption && + d.stat_consumption !== this._params?.device?.stat_consumption && + !children.includes(d.stat_consumption) + ); + } + + public closeDialog() { + this._params = undefined; + this._device = undefined; + this._error = undefined; + this._excludeList = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + return true; + } + + protected render() { + if (!this._params) { + return nothing; + } + + const pickableUnit = this._volume_units?.join(", ") || ""; + + return html` + + ${this.hass.localize( + "ui.panel.config.energy.device_consumption_water.dialog.header" + )}`} + @closed=${this.closeDialog} + > + ${this._error ? html`

${this._error}

` : ""} +
+ ${this.hass.localize( + "ui.panel.config.energy.device_consumption_water.dialog.selected_stat_intro", + { unit: pickableUnit } + )} +
+ + + + + + + + ${!this._possibleParents.length + ? html` + ${this.hass.localize( + "ui.panel.config.energy.device_consumption_water.dialog.no_upstream_devices" + )} + ` + : this._possibleParents.map( + (stat) => html` + ${stat.name || + getStatisticLabel( + this.hass, + stat.stat_consumption, + this._params?.statsMetadata?.[stat.stat_consumption] + )} + ` + )} + + + + ${this.hass.localize("ui.common.cancel")} + + + ${this.hass.localize("ui.common.save")} + +
+ `; + } + + private _statisticChanged(ev: CustomEvent<{ value: string }>) { + if (!ev.detail.value) { + this._device = undefined; + return; + } + this._device = { stat_consumption: ev.detail.value }; + this._computePossibleParents(); + } + + private _nameChanged(ev) { + const newDevice = { + ...this._device!, + name: ev.target!.value, + } as DeviceConsumptionEnergyPreference; + if (!newDevice.name) { + delete newDevice.name; + } + this._device = newDevice; + } + + private _parentSelected(ev) { + const newDevice = { + ...this._device!, + included_in_stat: ev.target!.value, + } as DeviceConsumptionEnergyPreference; + if (!newDevice.included_in_stat) { + delete newDevice.included_in_stat; + } + this._device = newDevice; + } + + private async _save() { + try { + await this._params!.saveCallback(this._device!); + this.closeDialog(); + } catch (err: any) { + this._error = err.message; + } + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + ha-statistic-picker { + width: 100%; + } + ha-select { + margin-top: 16px; + width: 100%; + } + ha-textfield { + margin-top: 16px; + width: 100%; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-energy-device-settings-water": DialogEnergyDeviceSettingsWater; + } +} diff --git a/src/panels/config/energy/dialogs/show-dialogs-energy.ts b/src/panels/config/energy/dialogs/show-dialogs-energy.ts index d2845e5e26..49a9069c43 100644 --- a/src/panels/config/energy/dialogs/show-dialogs-energy.ts +++ b/src/panels/config/energy/dialogs/show-dialogs-energy.ts @@ -83,6 +83,13 @@ export interface EnergySettingsDeviceDialogParams { saveCallback: (device: DeviceConsumptionEnergyPreference) => Promise; } +export interface EnergySettingsDeviceWaterDialogParams { + device?: DeviceConsumptionEnergyPreference; + device_consumptions: DeviceConsumptionEnergyPreference[]; + statsMetadata?: Record; + saveCallback: (device: DeviceConsumptionEnergyPreference) => Promise; +} + export const showEnergySettingsDeviceDialog = ( element: HTMLElement, dialogParams: EnergySettingsDeviceDialogParams @@ -160,6 +167,17 @@ export const showEnergySettingsGridFlowToDialog = ( }); }; +export const showEnergySettingsDeviceWaterDialog = ( + element: HTMLElement, + dialogParams: EnergySettingsDeviceWaterDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-energy-device-settings-water", + dialogImport: () => import("./dialog-energy-device-settings-water"), + dialogParams: dialogParams, + }); +}; + export const showEnergySettingsGridPowerDialog = ( element: HTMLElement, dialogParams: EnergySettingsGridPowerDialogParams diff --git a/src/panels/config/energy/ha-config-energy.ts b/src/panels/config/energy/ha-config-energy.ts index e0d44925bf..d3b5caf014 100644 --- a/src/panels/config/energy/ha-config-energy.ts +++ b/src/panels/config/energy/ha-config-energy.ts @@ -22,6 +22,7 @@ import { haStyle } from "../../../resources/styles"; import type { HomeAssistant, Route } from "../../../types"; import "../../../components/ha-alert"; import "./components/ha-energy-device-settings"; +import "./components/ha-energy-device-settings-water"; import "./components/ha-energy-grid-settings"; import "./components/ha-energy-solar-settings"; import "./components/ha-energy-battery-settings"; @@ -32,6 +33,7 @@ import { fileDownload } from "../../../util/file_download"; const INITIAL_CONFIG: EnergyPreferences = { energy_sources: [], device_consumption: [], + device_consumption_water: [], }; @customElement("ha-config-energy") @@ -142,6 +144,13 @@ class HaConfigEnergy extends LitElement { .validationResult=${this._validationResult} @value-changed=${this._prefsChanged} > + `; diff --git a/src/panels/energy/cards/energy-setup-wizard-card.ts b/src/panels/energy/cards/energy-setup-wizard-card.ts index b440d2cc68..86a6fc46ed 100644 --- a/src/panels/energy/cards/energy-setup-wizard-card.ts +++ b/src/panels/energy/cards/energy-setup-wizard-card.ts @@ -30,6 +30,7 @@ export class EnergySetupWizard extends LitElement implements LovelaceCard { @state() private _preferences: EnergyPreferences = { energy_sources: [], device_consumption: [], + device_consumption_water: [], }; public getCardSize() { diff --git a/src/translations/en.json b/src/translations/en.json index 1adefb96d5..22a3582c26 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -3239,6 +3239,22 @@ "included_in_device_helper": "If this device is already counted by another device (such as a smart switch measured by a smart breaker), selecting the upstream device prevents duplicate energy tracking.", "no_upstream_devices": "No eligible upstream devices" } + }, + "device_consumption_water": { + "title": "Individual water devices", + "sub": "Tracking the water usage of individual devices allows Home Assistant to break down your water usage by device.", + "learn_more": "More information on how to get started.", + "devices": "Devices", + "add_device": "Add device", + "dialog": { + "header": "Add a water device", + "display_name": "Display name", + "device_consumption_water": "Device water consumption", + "selected_stat_intro": "Select the water sensor that measures the device's water usage in either of {unit}.", + "included_in_device": "Upstream device", + "included_in_device_helper": "If this device is already counted by another device (such as a water meter measured by the main water supply), selecting the upstream device prevents duplicate water tracking.", + "no_upstream_devices": "No eligible upstream devices" + } } }, "helpers": {