diff --git a/demo/src/stubs/energy.ts b/demo/src/stubs/energy.ts index 6df7bde7fbb8..c038828c5c3e 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 a91c8f2e302a..1b5fbc375367 100644 --- a/src/data/energy.ts +++ b/src/data/energy.ts @@ -191,6 +191,7 @@ export type EnergySource = export interface EnergyPreferences { energy_sources: EnergySource[]; device_consumption: DeviceConsumptionEnergyPreference[]; + device_consumption_water: DeviceConsumptionEnergyPreference[]; } export interface EnergyInfo { @@ -347,6 +348,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 000000000000..0647a134adbf --- /dev/null +++ b/src/panels/config/energy/components/ha-energy-device-settings-water.ts @@ -0,0 +1,249 @@ +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.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 000000000000..7e2d625469c2 --- /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 f85dc549e623..fe79c6da83c3 100644 --- a/src/panels/config/energy/dialogs/show-dialogs-energy.ts +++ b/src/panels/config/energy/dialogs/show-dialogs-energy.ts @@ -76,6 +76,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 @@ -152,3 +159,14 @@ export const showEnergySettingsGridFlowToDialog = ( dialogParams: { ...dialogParams, direction: "to" }, }); }; + +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, + }); +}; diff --git a/src/panels/config/energy/ha-config-energy.ts b/src/panels/config/energy/ha-config-energy.ts index e0d44925bfc3..d3b5caf01435 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 b440d2cc68e9..86a6fc46ed20 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 dddeae42addb..1936c8c71b09 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -3208,6 +3208,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": {