diff --git a/src/data/energy.ts b/src/data/energy.ts index ff7000e962..b647807a9d 100644 --- a/src/data/energy.ts +++ b/src/data/energy.ts @@ -96,6 +96,7 @@ export interface DeviceConsumptionEnergyPreference { // This is an ever increasing value stat_consumption: string; name?: string; + included_in_stat?: string; } export interface FlowFromGridSourceEnergyPreference { diff --git a/src/panels/config/energy/components/ha-energy-device-settings.ts b/src/panels/config/energy/components/ha-energy-device-settings.ts index ae09836423..59d28b8a23 100644 --- a/src/panels/config/energy/components/ha-energy-device-settings.ts +++ b/src/panels/config/energy/components/ha-energy-device-settings.ts @@ -147,6 +147,7 @@ export class EnergyDeviceSettings extends LitElement { const origDevice: DeviceConsumptionEnergyPreference = ev.currentTarget.closest(".row").device; showEnergySettingsDeviceDialog(this, { + statsMetadata: this.statsMetadata, device: { ...origDevice }, device_consumptions: this.preferences .device_consumption as DeviceConsumptionEnergyPreference[], @@ -163,6 +164,7 @@ export class EnergyDeviceSettings extends LitElement { private _addDevice() { showEnergySettingsDeviceDialog(this, { + statsMetadata: this.statsMetadata, device_consumptions: this.preferences .device_consumption as DeviceConsumptionEnergyPreference[], saveCallback: async (device) => { @@ -188,12 +190,21 @@ export class EnergyDeviceSettings extends LitElement { } try { - await this._savePreferences({ + const newPrefs = { ...this.preferences, device_consumption: this.preferences.device_consumption.filter( (device) => device !== deviceToDelete ), + }; + newPrefs.device_consumption.forEach((d, idx) => { + if (d.included_in_stat === deviceToDelete.stat_consumption) { + newPrefs.device_consumption[idx] = { + ...newPrefs.device_consumption[idx], + }; + delete newPrefs.device_consumption[idx].included_in_stat; + } }); + await this._savePreferences(newPrefs); } catch (err: any) { showAlertDialog(this, { title: `Failed to save config: ${err.message}` }); } diff --git a/src/panels/config/energy/dialogs/dialog-energy-device-settings.ts b/src/panels/config/energy/dialogs/dialog-energy-device-settings.ts index b58f5f4c46..cb190faf13 100644 --- a/src/panels/config/energy/dialogs/dialog-energy-device-settings.ts +++ b/src/panels/config/energy/dialogs/dialog-energy-device-settings.ts @@ -4,13 +4,16 @@ 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-formfield"; import "../../../../components/ha-radio"; +import "../../../../components/ha-select"; 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"; @@ -36,19 +39,45 @@ export class DialogEnergyDeviceSettings private _excludeList?: string[]; + private _possibleParents: DeviceConsumptionEnergyPreference[] = []; + public async showDialog( params: EnergySettingsDeviceDialogParams ): Promise { this._params = params; + this._device = this._params.device; + this._computePossibleParents(); this._energy_units = ( await getSensorDeviceClassConvertibleUnits(this.hass, "energy") ).units; - this._device = this._params.device; 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 && + !children.includes(d.stat_consumption) + ); + } + public closeDialog() { this._params = undefined; this._device = undefined; @@ -105,10 +134,46 @@ export class DialogEnergyDeviceSettings type="text" .disabled=${!this._device} .value=${this._device?.name || ""} + .placeholder=${this._device + ? getStatisticLabel( + this.hass, + this._device.stat_consumption, + this._params?.statsMetadata?.[this._device.stat_consumption] + ) + : ""} @input=${this._nameChanged} > + + ${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")} @@ -129,6 +194,7 @@ export class DialogEnergyDeviceSettings return; } this._device = { stat_consumption: ev.detail.value }; + this._computePossibleParents(); } private _nameChanged(ev) { @@ -142,6 +208,17 @@ export class DialogEnergyDeviceSettings 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!); @@ -158,6 +235,10 @@ export class DialogEnergyDeviceSettings ha-statistic-picker { width: 100%; } + ha-select { + margin-top: 16px; + width: 100%; + } ha-textfield { margin-top: 16px; width: 100%; diff --git a/src/panels/config/energy/dialogs/show-dialogs-energy.ts b/src/panels/config/energy/dialogs/show-dialogs-energy.ts index 55092f461a..f85dc549e6 100644 --- a/src/panels/config/energy/dialogs/show-dialogs-energy.ts +++ b/src/panels/config/energy/dialogs/show-dialogs-energy.ts @@ -72,6 +72,7 @@ export interface EnergySettingsWaterDialogParams { export interface EnergySettingsDeviceDialogParams { device?: DeviceConsumptionEnergyPreference; device_consumptions: DeviceConsumptionEnergyPreference[]; + statsMetadata?: Record; saveCallback: (device: DeviceConsumptionEnergyPreference) => Promise; } diff --git a/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts index c3e64acad6..f36f39a9de 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts @@ -216,6 +216,16 @@ export class HuiEnergyDevicesDetailGraphCard const computedStyle = getComputedStyle(this); + const devices = energyData.prefs.device_consumption; + + const childMap: Record = {}; + devices.forEach((d) => { + if (d.included_in_stat) { + childMap[d.included_in_stat] = childMap[d.included_in_stat] || []; + childMap[d.included_in_stat].push(d.stat_consumption); + } + }); + const growthValues = {}; energyData.prefs.device_consumption.forEach((device) => { const value = @@ -225,11 +235,22 @@ export class HuiEnergyDevicesDetailGraphCard growthValues[device.stat_consumption] = value; }); + const growthValuesExChildren = {}; + energyData.prefs.device_consumption.forEach((device) => { + growthValuesExChildren[device.stat_consumption] = ( + childMap[device.stat_consumption] || [] + ).reduce( + (acc, child) => acc - growthValues[child], + growthValues[device.stat_consumption] + ); + }); const sorted_devices = energyData.prefs.device_consumption.map( (device) => device.stat_consumption ); - sorted_devices.sort((a, b) => growthValues[b] - growthValues[a]); + sorted_devices.sort( + (a, b) => growthValuesExChildren[b] - growthValuesExChildren[a] + ); const datasets: BarSeriesOption[] = []; @@ -254,6 +275,7 @@ export class HuiEnergyDevicesDetailGraphCard energyData.statsMetadata, energyData.prefs.device_consumption, sorted_devices, + childMap, true ); @@ -268,25 +290,24 @@ export class HuiEnergyDevicesDetailGraphCard ); datasets.push(untrackedCompareData); } - } else { - // add empty dataset so compare bars are first - // `stack: devices` so it doesn't take up space yet - const firstId = - energyData.prefs.device_consumption[0]?.stat_consumption ?? "untracked"; - datasets.push({ - id: "compare-" + firstId, - type: "bar", - stack: "devices", - data: [], - }); } + // add empty dataset so compare bars are first + // `stack: devices` so it doesn't take up space yet + datasets.push({ + id: "compare-placeholder", + type: "bar", + stack: energyData.statsCompare ? "devicesCompare" : "devices", + data: [], + }); + const processedData = this._processDataSet( computedStyle, data, energyData.statsMetadata, energyData.prefs.device_consumption, - sorted_devices + sorted_devices, + childMap ); datasets.push(...processedData); @@ -377,6 +398,7 @@ export class HuiEnergyDevicesDetailGraphCard statisticsMetaData: Record, devices: DeviceConsumptionEnergyPreference[], sorted_devices: string[], + childMap: Record, compare = false ) { const data: BarSeriesOption[] = []; @@ -400,7 +422,7 @@ export class HuiEnergyDevicesDetailGraphCard const consumptionData: BarSeriesOption["data"] = []; - // Process gas consumption data. + // Process device consumption data. if (source.stat_consumption in statistics) { const stats = statistics[source.stat_consumption]; @@ -415,7 +437,15 @@ export class HuiEnergyDevicesDetailGraphCard if (prevStart === point.start) { continue; } - const dataPoint = [point.start, point.change]; + let sumChildren = 0; + const children = childMap[source.stat_consumption] || []; + children.forEach((c) => { + const cStats = statistics[c]; + sumChildren += + cStats?.find((cStat) => cStat.start === point.start)?.change || 0; + }); + + const dataPoint = [point.start, point.change - sumChildren]; if (compare) { dataPoint[2] = dataPoint[0]; dataPoint[0] = compareTransform(new Date(point.start)).getTime(); @@ -425,6 +455,17 @@ export class HuiEnergyDevicesDetailGraphCard } } + const name = + (source.name || + getStatisticLabel( + this.hass, + source.stat_consumption, + statisticsMetaData[source.stat_consumption] + )) + + (source.stat_consumption in childMap + ? ` (${this.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked")})` + : ""); + data.push({ type: "bar", cursor: "default", @@ -432,13 +473,7 @@ export class HuiEnergyDevicesDetailGraphCard id: compare ? `compare-${source.stat_consumption}-${order}` : `${source.stat_consumption}-${order}`, - name: - source.name || - getStatisticLabel( - this.hass, - source.stat_consumption, - statisticsMetaData[source.stat_consumption] - ), + name, itemStyle: { borderColor: compare ? color + "7F" : color, }, @@ -451,16 +486,17 @@ export class HuiEnergyDevicesDetailGraphCard return sorted_devices .map( (device) => - data.find((d) => { - const id = (d.id as string) - .replace(/^compare-/, "") // Remove compare- prefix - .replace(/-\d+$/, ""); // Remove numeric suffix - return id === device; - })! + data.find((d) => this._getStatIdFromId(d.id as string) === device)! ) .filter(Boolean); } + private _getStatIdFromId(id: string): string { + return id + .replace(/^compare-/, "") // Remove compare- prefix + .replace(/-\d+$/, ""); // Remove numeric suffix + } + static styles = css` .card-header { padding-bottom: 0; diff --git a/src/translations/en.json b/src/translations/en.json index 70b3e537a5..1896421fa2 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2897,7 +2897,9 @@ "header": "Add a device", "display_name": "Display name", "device_consumption_energy": "Device energy consumption", - "selected_stat_intro": "Select the energy sensor that measures the device's energy usage in either of {unit}." + "selected_stat_intro": "Select the energy sensor that measures the device's energy 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 smart switch measured by a smart breaker), selecting the upstream device prevents duplicate energy tracking." } } }, @@ -6422,7 +6424,8 @@ "previous_energy_usage": "Previous energy usage" }, "energy_devices_detail_graph": { - "untracked_consumption": "Untracked consumption" + "untracked_consumption": "Untracked consumption", + "untracked": "untracked" }, "carbon_consumed_gauge": { "card_indicates_energy_used": "This card indicates how much of the electricity consumed by your home was generated using non-fossil fuels like solar, wind, and nuclear. The higher, the better!",