Support for hierarchy of individual energy devices (#23185)

* Support for hierarchy of individual energy devices

* better config ui

* replace parent_stat w included_in_stat

* semi-working

* update order suffix in id when hidden changes

* rollback some ordering changes, update strings

* Remove hidden tracking, add untracked label to name.

* Update dialog-energy-device-settings.ts

* Change sort algorithm
This commit is contained in:
karwosts 2025-03-24 04:39:19 -07:00 committed by GitHub
parent 8dab7c598e
commit 6fbc7b2efe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 165 additions and 32 deletions

View File

@ -96,6 +96,7 @@ export interface DeviceConsumptionEnergyPreference {
// This is an ever increasing value // This is an ever increasing value
stat_consumption: string; stat_consumption: string;
name?: string; name?: string;
included_in_stat?: string;
} }
export interface FlowFromGridSourceEnergyPreference { export interface FlowFromGridSourceEnergyPreference {

View File

@ -147,6 +147,7 @@ export class EnergyDeviceSettings extends LitElement {
const origDevice: DeviceConsumptionEnergyPreference = const origDevice: DeviceConsumptionEnergyPreference =
ev.currentTarget.closest(".row").device; ev.currentTarget.closest(".row").device;
showEnergySettingsDeviceDialog(this, { showEnergySettingsDeviceDialog(this, {
statsMetadata: this.statsMetadata,
device: { ...origDevice }, device: { ...origDevice },
device_consumptions: this.preferences device_consumptions: this.preferences
.device_consumption as DeviceConsumptionEnergyPreference[], .device_consumption as DeviceConsumptionEnergyPreference[],
@ -163,6 +164,7 @@ export class EnergyDeviceSettings extends LitElement {
private _addDevice() { private _addDevice() {
showEnergySettingsDeviceDialog(this, { showEnergySettingsDeviceDialog(this, {
statsMetadata: this.statsMetadata,
device_consumptions: this.preferences device_consumptions: this.preferences
.device_consumption as DeviceConsumptionEnergyPreference[], .device_consumption as DeviceConsumptionEnergyPreference[],
saveCallback: async (device) => { saveCallback: async (device) => {
@ -188,12 +190,21 @@ export class EnergyDeviceSettings extends LitElement {
} }
try { try {
await this._savePreferences({ const newPrefs = {
...this.preferences, ...this.preferences,
device_consumption: this.preferences.device_consumption.filter( device_consumption: this.preferences.device_consumption.filter(
(device) => device !== deviceToDelete (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) { } catch (err: any) {
showAlertDialog(this, { title: `Failed to save config: ${err.message}` }); showAlertDialog(this, { title: `Failed to save config: ${err.message}` });
} }

View File

@ -4,13 +4,16 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import "../../../../components/entity/ha-entity-picker"; import "../../../../components/entity/ha-entity-picker";
import "../../../../components/entity/ha-statistic-picker"; import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-dialog"; import "../../../../components/ha-dialog";
import "../../../../components/ha-formfield"; import "../../../../components/ha-formfield";
import "../../../../components/ha-radio"; import "../../../../components/ha-radio";
import "../../../../components/ha-select";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy"; import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { energyStatisticHelpUrl } from "../../../../data/energy"; import { energyStatisticHelpUrl } from "../../../../data/energy";
import { getStatisticLabel } from "../../../../data/recorder";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor"; import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles"; import { haStyleDialog } from "../../../../resources/styles";
@ -36,19 +39,45 @@ export class DialogEnergyDeviceSettings
private _excludeList?: string[]; private _excludeList?: string[];
private _possibleParents: DeviceConsumptionEnergyPreference[] = [];
public async showDialog( public async showDialog(
params: EnergySettingsDeviceDialogParams params: EnergySettingsDeviceDialogParams
): Promise<void> { ): Promise<void> {
this._params = params; this._params = params;
this._device = this._params.device;
this._computePossibleParents();
this._energy_units = ( this._energy_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "energy") await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
).units; ).units;
this._device = this._params.device;
this._excludeList = this._params.device_consumptions this._excludeList = this._params.device_consumptions
.map((entry) => entry.stat_consumption) .map((entry) => entry.stat_consumption)
.filter((id) => id !== this._device?.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() { public closeDialog() {
this._params = undefined; this._params = undefined;
this._device = undefined; this._device = undefined;
@ -105,10 +134,46 @@ export class DialogEnergyDeviceSettings
type="text" type="text"
.disabled=${!this._device} .disabled=${!this._device}
.value=${this._device?.name || ""} .value=${this._device?.name || ""}
.placeholder=${this._device
? getStatisticLabel(
this.hass,
this._device.stat_consumption,
this._params?.statsMetadata?.[this._device.stat_consumption]
)
: ""}
@input=${this._nameChanged} @input=${this._nameChanged}
> >
</ha-textfield> </ha-textfield>
<ha-select
.label=${this.hass.localize(
"ui.panel.config.energy.device_consumption.dialog.included_in_device"
)}
.value=${this._device?.included_in_stat || ""}
.helper=${this.hass.localize(
"ui.panel.config.energy.device_consumption.dialog.included_in_device_helper"
)}
.disabled=${!this._device}
@selected=${this._parentSelected}
@closed=${stopPropagation}
fixedMenuPosition
naturalMenuWidth
clearable
>
${this._possibleParents.map(
(stat) => html`
<mwc-list-item .value=${stat.stat_consumption}
>${stat.name ||
getStatisticLabel(
this.hass,
stat.stat_consumption,
this._params?.statsMetadata?.[stat.stat_consumption]
)}</mwc-list-item
>
`
)}
</ha-select>
<mwc-button @click=${this.closeDialog} slot="secondaryAction"> <mwc-button @click=${this.closeDialog} slot="secondaryAction">
${this.hass.localize("ui.common.cancel")} ${this.hass.localize("ui.common.cancel")}
</mwc-button> </mwc-button>
@ -129,6 +194,7 @@ export class DialogEnergyDeviceSettings
return; return;
} }
this._device = { stat_consumption: ev.detail.value }; this._device = { stat_consumption: ev.detail.value };
this._computePossibleParents();
} }
private _nameChanged(ev) { private _nameChanged(ev) {
@ -142,6 +208,17 @@ export class DialogEnergyDeviceSettings
this._device = newDevice; 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() { private async _save() {
try { try {
await this._params!.saveCallback(this._device!); await this._params!.saveCallback(this._device!);
@ -158,6 +235,10 @@ export class DialogEnergyDeviceSettings
ha-statistic-picker { ha-statistic-picker {
width: 100%; width: 100%;
} }
ha-select {
margin-top: 16px;
width: 100%;
}
ha-textfield { ha-textfield {
margin-top: 16px; margin-top: 16px;
width: 100%; width: 100%;

View File

@ -72,6 +72,7 @@ export interface EnergySettingsWaterDialogParams {
export interface EnergySettingsDeviceDialogParams { export interface EnergySettingsDeviceDialogParams {
device?: DeviceConsumptionEnergyPreference; device?: DeviceConsumptionEnergyPreference;
device_consumptions: DeviceConsumptionEnergyPreference[]; device_consumptions: DeviceConsumptionEnergyPreference[];
statsMetadata?: Record<string, StatisticsMetaData>;
saveCallback: (device: DeviceConsumptionEnergyPreference) => Promise<void>; saveCallback: (device: DeviceConsumptionEnergyPreference) => Promise<void>;
} }

View File

@ -216,6 +216,16 @@ export class HuiEnergyDevicesDetailGraphCard
const computedStyle = getComputedStyle(this); const computedStyle = getComputedStyle(this);
const devices = energyData.prefs.device_consumption;
const childMap: Record<string, string[]> = {};
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 = {}; const growthValues = {};
energyData.prefs.device_consumption.forEach((device) => { energyData.prefs.device_consumption.forEach((device) => {
const value = const value =
@ -225,11 +235,22 @@ export class HuiEnergyDevicesDetailGraphCard
growthValues[device.stat_consumption] = value; 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( const sorted_devices = energyData.prefs.device_consumption.map(
(device) => device.stat_consumption (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[] = []; const datasets: BarSeriesOption[] = [];
@ -254,6 +275,7 @@ export class HuiEnergyDevicesDetailGraphCard
energyData.statsMetadata, energyData.statsMetadata,
energyData.prefs.device_consumption, energyData.prefs.device_consumption,
sorted_devices, sorted_devices,
childMap,
true true
); );
@ -268,25 +290,24 @@ export class HuiEnergyDevicesDetailGraphCard
); );
datasets.push(untrackedCompareData); datasets.push(untrackedCompareData);
} }
} else { }
// add empty dataset so compare bars are first // add empty dataset so compare bars are first
// `stack: devices` so it doesn't take up space yet // `stack: devices` so it doesn't take up space yet
const firstId =
energyData.prefs.device_consumption[0]?.stat_consumption ?? "untracked";
datasets.push({ datasets.push({
id: "compare-" + firstId, id: "compare-placeholder",
type: "bar", type: "bar",
stack: "devices", stack: energyData.statsCompare ? "devicesCompare" : "devices",
data: [], data: [],
}); });
}
const processedData = this._processDataSet( const processedData = this._processDataSet(
computedStyle, computedStyle,
data, data,
energyData.statsMetadata, energyData.statsMetadata,
energyData.prefs.device_consumption, energyData.prefs.device_consumption,
sorted_devices sorted_devices,
childMap
); );
datasets.push(...processedData); datasets.push(...processedData);
@ -377,6 +398,7 @@ export class HuiEnergyDevicesDetailGraphCard
statisticsMetaData: Record<string, StatisticsMetaData>, statisticsMetaData: Record<string, StatisticsMetaData>,
devices: DeviceConsumptionEnergyPreference[], devices: DeviceConsumptionEnergyPreference[],
sorted_devices: string[], sorted_devices: string[],
childMap: Record<string, string[]>,
compare = false compare = false
) { ) {
const data: BarSeriesOption[] = []; const data: BarSeriesOption[] = [];
@ -400,7 +422,7 @@ export class HuiEnergyDevicesDetailGraphCard
const consumptionData: BarSeriesOption["data"] = []; const consumptionData: BarSeriesOption["data"] = [];
// Process gas consumption data. // Process device consumption data.
if (source.stat_consumption in statistics) { if (source.stat_consumption in statistics) {
const stats = statistics[source.stat_consumption]; const stats = statistics[source.stat_consumption];
@ -415,7 +437,15 @@ export class HuiEnergyDevicesDetailGraphCard
if (prevStart === point.start) { if (prevStart === point.start) {
continue; 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) { if (compare) {
dataPoint[2] = dataPoint[0]; dataPoint[2] = dataPoint[0];
dataPoint[0] = compareTransform(new Date(point.start)).getTime(); 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({ data.push({
type: "bar", type: "bar",
cursor: "default", cursor: "default",
@ -432,13 +473,7 @@ export class HuiEnergyDevicesDetailGraphCard
id: compare id: compare
? `compare-${source.stat_consumption}-${order}` ? `compare-${source.stat_consumption}-${order}`
: `${source.stat_consumption}-${order}`, : `${source.stat_consumption}-${order}`,
name: name,
source.name ||
getStatisticLabel(
this.hass,
source.stat_consumption,
statisticsMetaData[source.stat_consumption]
),
itemStyle: { itemStyle: {
borderColor: compare ? color + "7F" : color, borderColor: compare ? color + "7F" : color,
}, },
@ -451,16 +486,17 @@ export class HuiEnergyDevicesDetailGraphCard
return sorted_devices return sorted_devices
.map( .map(
(device) => (device) =>
data.find((d) => { data.find((d) => this._getStatIdFromId(d.id as string) === device)!
const id = (d.id as string)
.replace(/^compare-/, "") // Remove compare- prefix
.replace(/-\d+$/, ""); // Remove numeric suffix
return id === device;
})!
) )
.filter(Boolean); .filter(Boolean);
} }
private _getStatIdFromId(id: string): string {
return id
.replace(/^compare-/, "") // Remove compare- prefix
.replace(/-\d+$/, ""); // Remove numeric suffix
}
static styles = css` static styles = css`
.card-header { .card-header {
padding-bottom: 0; padding-bottom: 0;

View File

@ -2897,7 +2897,9 @@
"header": "Add a device", "header": "Add a device",
"display_name": "Display name", "display_name": "Display name",
"device_consumption_energy": "Device energy consumption", "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" "previous_energy_usage": "Previous energy usage"
}, },
"energy_devices_detail_graph": { "energy_devices_detail_graph": {
"untracked_consumption": "Untracked consumption" "untracked_consumption": "Untracked consumption",
"untracked": "untracked"
}, },
"carbon_consumed_gauge": { "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!", "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!",