1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-04-17 15:45:43 +01:00

Migrate grid connections to single objects with import/export/power (#29389)

* Migrate grid connections to single objects with import/export/power

* Fix duplicate imports in battery settings dialog

* Update src/translations/en.json

Co-authored-by: Norbert Rittel <norbert@rittel.de>

* Update src/translations/en.json

Co-authored-by: Norbert Rittel <norbert@rittel.de>

* Remove redundant power grid translation keys from en.json

* Remove redundant power charge and discharge keys from en.json

* Clean up grid keys from en.json

* Rename sell price to export

* add descriptions

* use ValueChangedEvent

* Renamed translationKeyPrefix to localizeBaseKey

* Add clarification to stat_rate in energy preference interfaces

* Add handling for external statistics in energy cost tracking

* Apply suggestion from @NoRi2909

Co-authored-by: Norbert Rittel <norbert@rittel.de>

* Update src/translations/en.json

Co-authored-by: Norbert Rittel <norbert@rittel.de>

* update comments

* Use ha-dialog instead of ha-wa-dialog

---------

Co-authored-by: Norbert Rittel <norbert@rittel.de>
This commit is contained in:
Petar Petrov
2026-02-16 08:55:07 +01:00
committed by GitHub
parent c138608445
commit 010eee76c5
22 changed files with 1478 additions and 1822 deletions

View File

@@ -14,40 +14,28 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
energy_sources: [
{
type: "grid",
flow_from: [
{
stat_energy_from: "sensor.energy_consumption_tarif_1",
stat_cost: "sensor.energy_consumption_tarif_1_cost",
entity_energy_price: null,
number_energy_price: null,
},
{
stat_energy_from: "sensor.energy_consumption_tarif_2",
stat_cost: "sensor.energy_consumption_tarif_2_cost",
entity_energy_price: null,
number_energy_price: null,
},
],
flow_to: [
{
stat_energy_to: "sensor.energy_production_tarif_1",
stat_compensation:
"sensor.energy_production_tarif_1_compensation",
entity_energy_price: null,
number_energy_price: null,
},
{
stat_energy_to: "sensor.energy_production_tarif_2",
stat_compensation:
"sensor.energy_production_tarif_2_compensation",
entity_energy_price: null,
number_energy_price: null,
},
],
power: [
{ stat_rate: "sensor.power_grid" },
{ stat_rate: "sensor.power_grid_return" },
],
stat_energy_from: "sensor.energy_consumption_tarif_1",
stat_energy_to: "sensor.energy_production_tarif_1",
stat_cost: "sensor.energy_consumption_tarif_1_cost",
stat_compensation: "sensor.energy_production_tarif_1_compensation",
entity_energy_price: null,
number_energy_price: null,
entity_energy_price_export: null,
number_energy_price_export: null,
stat_rate: "sensor.power_grid",
cost_adjustment_day: 0,
},
{
type: "grid",
stat_energy_from: "sensor.energy_consumption_tarif_2",
stat_energy_to: "sensor.energy_production_tarif_2",
stat_cost: "sensor.energy_consumption_tarif_2_cost",
stat_compensation: "sensor.energy_production_tarif_2_compensation",
entity_energy_price: null,
number_energy_price: null,
entity_energy_price_export: null,
number_energy_price_export: null,
stat_rate: "sensor.power_grid_return",
cost_adjustment_day: 0,
},
{

View File

@@ -40,27 +40,17 @@ import { formatNumber } from "../common/number/format_number";
const energyCollectionKeys: (string | undefined)[] = [];
export const emptyFlowFromGridSourceEnergyPreference =
(): FlowFromGridSourceEnergyPreference => ({
stat_energy_from: "",
stat_cost: null,
entity_energy_price: null,
number_energy_price: null,
});
export const emptyFlowToGridSourceEnergyPreference =
(): FlowToGridSourceEnergyPreference => ({
stat_energy_to: "",
stat_compensation: null,
entity_energy_price: null,
number_energy_price: null,
});
export const emptyGridSourceEnergyPreference =
(): GridSourceTypeEnergyPreference => ({
type: "grid",
flow_from: [],
flow_to: [],
stat_energy_from: null,
stat_energy_to: null,
stat_cost: null,
stat_compensation: null,
entity_energy_price: null,
number_energy_price: null,
entity_energy_price_export: null,
number_energy_price_export: null,
cost_adjustment_day: 0,
});
@@ -108,30 +98,6 @@ export interface DeviceConsumptionEnergyPreference {
included_in_stat?: string;
}
export interface FlowFromGridSourceEnergyPreference {
// kWh meter
stat_energy_from: string;
// $ meter
stat_cost: string | null;
// Can be used to generate costs if stat_cost omitted
entity_energy_price: string | null;
number_energy_price: number | null;
}
export interface FlowToGridSourceEnergyPreference {
// kWh meter
stat_energy_to: string;
// $ meter
stat_compensation: string | null;
// Can be used to generate costs if stat_compensation omitted
entity_energy_price: string | null;
number_energy_price: number | null;
}
export interface PowerConfig {
stat_rate?: string; // Standard single sensor
stat_rate_inverted?: string; // Inverted single sensor
@@ -139,29 +105,33 @@ export interface PowerConfig {
stat_rate_to?: string; // Battery: charge / Grid: return
}
export interface GridPowerSourceEnergyPreference {
stat_rate: string;
power_config?: PowerConfig;
}
/**
* Input type for saving grid power sources.
* Core requires EITHER stat_rate (legacy) OR power_config (new format).
* When reading from backend, stat_rate is always populated.
* Grid source format.
* Each grid connection is a single object with import/export/power together.
* Multiple grid sources are allowed.
*/
export type GridPowerSourceInput = Omit<
GridPowerSourceEnergyPreference,
"stat_rate"
> & {
stat_rate?: string;
};
export interface GridSourceTypeEnergyPreference {
type: "grid";
flow_from: FlowFromGridSourceEnergyPreference[];
flow_to: FlowToGridSourceEnergyPreference[];
power?: GridPowerSourceEnergyPreference[];
// Import meter
stat_energy_from: string | null;
// Export meter
stat_energy_to: string | null;
// Import cost tracking
stat_cost: string | null;
entity_energy_price: string | null;
number_energy_price: number | null;
// Export compensation tracking
stat_compensation: string | null;
entity_energy_price_export: string | null;
number_energy_price_export: number | null;
// Power measurement
stat_rate?: string; // always available if power_config is set
power_config?: PowerConfig;
cost_adjustment_day: number;
}
@@ -178,7 +148,7 @@ export interface BatterySourceTypeEnergyPreference {
type: "battery";
stat_energy_from: string;
stat_energy_to: string;
stat_rate?: string;
stat_rate?: string; // always available if power_config is set
power_config?: PowerConfig;
}
export interface GasSourceTypeEnergyPreference {
@@ -355,24 +325,25 @@ export const getReferencedStatisticIds = (
}
// grid source
for (const flowFrom of source.flow_from) {
statIDs.push(flowFrom.stat_energy_from);
if (flowFrom.stat_cost) {
statIDs.push(flowFrom.stat_cost);
if (source.stat_energy_from) {
statIDs.push(source.stat_energy_from);
if (source.stat_cost) {
statIDs.push(source.stat_cost);
}
const costStatId = info.cost_sensors[flowFrom.stat_energy_from];
if (costStatId) {
statIDs.push(costStatId);
const importCostStatId = info.cost_sensors[source.stat_energy_from];
if (importCostStatId) {
statIDs.push(importCostStatId);
}
}
for (const flowTo of source.flow_to) {
statIDs.push(flowTo.stat_energy_to);
if (flowTo.stat_compensation) {
statIDs.push(flowTo.stat_compensation);
if (source.stat_energy_to) {
statIDs.push(source.stat_energy_to);
if (source.stat_compensation) {
statIDs.push(source.stat_compensation);
}
const costStatId = info.cost_sensors[flowTo.stat_energy_to];
if (costStatId) {
statIDs.push(costStatId);
const exportCostStatId = info.cost_sensors[source.stat_energy_to];
if (exportCostStatId) {
statIDs.push(exportCostStatId);
}
}
}
@@ -404,12 +375,15 @@ export const getReferencedStatisticIdsPower = (
}
if (source.type === "battery") {
statIDs.push(source.stat_rate);
if (source.stat_rate) {
statIDs.push(source.stat_rate);
}
continue;
}
if (source.power) {
statIDs.push(...source.power.map((p) => p.stat_rate));
// grid source
if (source.stat_rate) {
statIDs.push(source.stat_rate);
}
}
statIDs.push(...prefs.device_consumption.map((d) => d.stat_rate));
@@ -450,11 +424,8 @@ const getEnergyData = async (
const consumptionStatIDs: string[] = [];
for (const source of prefs.energy_sources) {
// grid source
if (source.type === "grid") {
for (const flowFrom of source.flow_from) {
consumptionStatIDs.push(flowFrom.stat_energy_from);
}
if (source.type === "grid" && source.stat_energy_from) {
consumptionStatIDs.push(source.stat_energy_from);
}
}
const energyStatIds = getReferencedStatisticIds(prefs, info, [
@@ -1055,18 +1026,18 @@ const getSummedDataPartial = (
}
// grid source
for (const flowFrom of source.flow_from) {
if (source.stat_energy_from) {
if (statIds.from_grid) {
statIds.from_grid.push(flowFrom.stat_energy_from);
statIds.from_grid.push(source.stat_energy_from);
} else {
statIds.from_grid = [flowFrom.stat_energy_from];
statIds.from_grid = [source.stat_energy_from];
}
}
for (const flowTo of source.flow_to) {
if (source.stat_energy_to) {
if (statIds.to_grid) {
statIds.to_grid.push(flowTo.stat_energy_to);
statIds.to_grid.push(source.stat_energy_to);
} else {
statIds.to_grid = [flowTo.stat_energy_to];
statIds.to_grid = [source.stat_energy_to];
}
}
}

View File

@@ -1,13 +1,6 @@
import {
mdiDelete,
mdiHomeExportOutline,
mdiHomeImportOutline,
mdiPencil,
mdiPlus,
mdiTransmissionTower,
} from "@mdi/js";
import { mdiDelete, mdiPencil, mdiPlus, mdiTransmissionTower } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-card";
@@ -23,18 +16,9 @@ import type {
EnergyPreferences,
EnergyPreferencesValidation,
EnergyValidationIssue,
EnergySource,
FlowFromGridSourceEnergyPreference,
FlowToGridSourceEnergyPreference,
GridPowerSourceEnergyPreference,
GridPowerSourceInput,
GridSourceTypeEnergyPreference,
} from "../../../../data/energy";
import {
emptyGridSourceEnergyPreference,
energySourcesByType,
saveEnergyPreferences,
} from "../../../../data/energy";
import { saveEnergyPreferences } from "../../../../data/energy";
import type { StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import { showConfigFlowDialog } from "../../../../dialogs/config-flow/show-dialog-config-flow";
@@ -46,11 +30,7 @@ import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { brandsUrl } from "../../../../util/brands-url";
import { documentationUrl } from "../../../../util/documentation-url";
import {
showEnergySettingsGridFlowFromDialog,
showEnergySettingsGridFlowToDialog,
showEnergySettingsGridPowerDialog,
} from "../dialogs/show-dialogs-energy";
import { showEnergySettingsGridDialog } from "../dialogs/show-dialogs-energy";
import "./ha-energy-validation-result";
import { energyCardStyles } from "./styles";
@@ -74,23 +54,19 @@ export class EnergyGridSettings extends LitElement {
}
protected render(): TemplateResult {
const gridIdx = this.preferences.energy_sources.findIndex(
(source) => source.type === "grid"
);
const gridSources: GridSourceTypeEnergyPreference[] = [];
const gridValidation: EnergyValidationIssue[][] = [];
let gridSource: GridSourceTypeEnergyPreference;
let gridValidation: EnergyValidationIssue[] | undefined;
if (gridIdx === -1) {
gridSource = emptyGridSourceEnergyPreference();
} else {
gridSource = this.preferences.energy_sources[
gridIdx
] as GridSourceTypeEnergyPreference;
if (this.validationResult) {
gridValidation = this.validationResult.energy_sources[gridIdx];
this.preferences.energy_sources.forEach((source, idx) => {
if (source.type !== "grid") {
return;
}
}
gridSources.push(source);
if (this.validationResult) {
gridValidation.push(this.validationResult.energy_sources[idx]);
}
});
return html`
<ha-card outlined>
@@ -114,154 +90,65 @@ export class EnergyGridSettings extends LitElement {
)}</a
>
</p>
${gridValidation
? html`
<ha-energy-validation-result
.hass=${this.hass}
.issues=${gridValidation}
></ha-energy-validation-result>
`
: ""}
${gridValidation.map(
(result) => html`
<ha-energy-validation-result
.hass=${this.hass}
.issues=${result}
></ha-energy-validation-result>
`
)}
<h3>
${this.hass.localize(
"ui.panel.config.energy.grid.grid_consumption"
"ui.panel.config.energy.grid.grid_connections"
)}
</h3>
${gridSource.flow_from.map((flow) => {
const entityState = this.hass.states[flow.stat_energy_from];
${gridSources.map((source, idx) => {
// At least one of import/export/power must exist (enforced by validation)
const primaryStat = (source.stat_energy_from ||
source.stat_energy_to ||
source.stat_rate)!;
const primaryEntityState = this.hass.states[primaryStat];
return html`
<div class="row" .source=${flow}>
${entityState?.attributes.icon
<div class="row" .source=${source} .sourceIndex=${idx}>
${primaryEntityState?.attributes.icon
? html`<ha-icon
.icon=${entityState?.attributes.icon}
></ha-icon>`
: html`<ha-svg-icon
.path=${mdiHomeImportOutline}
></ha-svg-icon>`}
<span class="content"
>${getStatisticLabel(
this.hass,
flow.stat_energy_from,
this.statsMetadata?.[flow.stat_energy_from]
)}</span
>
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.energy.grid.edit_consumption"
)}
@click=${this._editFromSource}
.path=${mdiPencil}
></ha-icon-button>
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.energy.grid.delete_consumption"
)}
@click=${this._deleteFromSource}
.path=${mdiDelete}
></ha-icon-button>
</div>
`;
})}
<div class="row border-bottom">
<ha-svg-icon .path=${mdiHomeImportOutline}></ha-svg-icon>
<ha-button
appearance="filled"
size="small"
@click=${this._addFromSource}
>
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.energy.grid.add_consumption"
)}</ha-button
>
</div>
<h3>
${this.hass.localize("ui.panel.config.energy.grid.return_to_grid")}
</h3>
${gridSource.flow_to.map((flow) => {
const entityState = this.hass.states[flow.stat_energy_to];
return html`
<div class="row" .source=${flow}>
${entityState?.attributes.icon
? html`<ha-icon
.icon=${entityState.attributes.icon}
></ha-icon>`
: html`<ha-svg-icon
.path=${mdiHomeExportOutline}
></ha-svg-icon>`}
<span class="content"
>${getStatisticLabel(
this.hass,
flow.stat_energy_to,
this.statsMetadata?.[flow.stat_energy_to]
)}</span
>
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.energy.grid.edit_return"
)}
@click=${this._editToSource}
.path=${mdiPencil}
></ha-icon-button>
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.energy.grid.delete_return"
)}
@click=${this._deleteToSource}
.path=${mdiDelete}
></ha-icon-button>
</div>
`;
})}
<div class="row border-bottom">
<ha-svg-icon .path=${mdiHomeExportOutline}></ha-svg-icon>
<ha-button
@click=${this._addToSource}
appearance="filled"
size="small"
>
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.energy.grid.add_return"
)}</ha-button
>
</div>
<h3>
${this.hass.localize("ui.panel.config.energy.grid.grid_power")}
</h3>
${gridSource.power?.map((power) => {
const entityState = this.hass.states[power.stat_rate];
return html`
<div class="row" .source=${power}>
${entityState?.attributes.icon
? html`<ha-icon
.icon=${entityState.attributes.icon}
.icon=${primaryEntityState.attributes.icon}
></ha-icon>`
: html`<ha-svg-icon
.path=${mdiTransmissionTower}
></ha-svg-icon>`}
<span class="content"
>${getStatisticLabel(
this.hass,
power.stat_rate,
this.statsMetadata?.[power.stat_rate]
)}</span
>
<div class="content">
<span class="label"
>${getStatisticLabel(
this.hass,
primaryStat,
this.statsMetadata?.[primaryStat]
)}</span
>
${source.stat_energy_from && source.stat_energy_to
? html`<span class="label secondary"
>${getStatisticLabel(
this.hass,
source.stat_energy_to,
this.statsMetadata?.[source.stat_energy_to]
)}</span
>`
: nothing}
</div>
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.energy.grid.edit_power"
"ui.panel.config.energy.grid.edit_connection"
)}
@click=${this._editPowerSource}
@click=${this._editSource}
.path=${mdiPencil}
></ha-icon-button>
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.energy.grid.delete_power"
"ui.panel.config.energy.grid.delete_connection"
)}
@click=${this._deletePowerSource}
@click=${this._deleteSource}
.path=${mdiDelete}
></ha-icon-button>
</div>
@@ -270,13 +157,13 @@ export class EnergyGridSettings extends LitElement {
<div class="row border-bottom">
<ha-svg-icon .path=${mdiTransmissionTower}></ha-svg-icon>
<ha-button
@click=${this._addPowerSource}
@click=${this._addSource}
appearance="filled"
size="small"
>
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.energy.grid.add_power"
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.energy.grid.add_connection"
)}</ha-button
>
</div>
@@ -369,140 +256,53 @@ export class EnergyGridSettings extends LitElement {
this._fetchCO2SignalConfigEntries();
}
private _addFromSource() {
const gridSource = this.preferences.energy_sources.find(
(src) => src.type === "grid"
) as GridSourceTypeEnergyPreference | undefined;
showEnergySettingsGridFlowFromDialog(this, {
grid_source: gridSource,
saveCallback: async (flow) => {
let preferences: EnergyPreferences;
if (!gridSource) {
preferences = {
...this.preferences,
energy_sources: [
...this.preferences.energy_sources,
{
...emptyGridSourceEnergyPreference(),
flow_from: [flow],
},
],
};
} else {
preferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((src) =>
src.type === "grid"
? { ...src, flow_from: [...gridSource.flow_from, flow] }
: src
),
};
}
await this._savePreferences(preferences);
},
});
private _getGridSources(): GridSourceTypeEnergyPreference[] {
return this.preferences.energy_sources.filter(
(src): src is GridSourceTypeEnergyPreference => src.type === "grid"
);
}
private _addToSource() {
const gridSource = this.preferences.energy_sources.find(
(src) => src.type === "grid"
) as GridSourceTypeEnergyPreference | undefined;
showEnergySettingsGridFlowToDialog(this, {
grid_source: gridSource,
saveCallback: async (flow) => {
let preferences: EnergyPreferences;
if (!gridSource) {
preferences = {
...this.preferences,
energy_sources: [
...this.preferences.energy_sources,
{
...emptyGridSourceEnergyPreference(),
flow_to: [flow],
},
],
};
} else {
preferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((src) =>
src.type === "grid"
? { ...src, flow_to: [...gridSource.flow_to, flow] }
: src
),
};
}
await this._savePreferences(preferences);
},
});
}
private _editFromSource(ev) {
const origSource: FlowFromGridSourceEnergyPreference =
ev.currentTarget.closest(".row").source;
const gridSource = this.preferences.energy_sources.find(
(src) => src.type === "grid"
) as GridSourceTypeEnergyPreference | undefined;
showEnergySettingsGridFlowFromDialog(this, {
source: { ...origSource },
grid_source: gridSource,
metadata: this.statsMetadata?.[origSource.stat_energy_from],
private _addSource() {
showEnergySettingsGridDialog(this, {
grid_sources: this._getGridSources(),
saveCallback: async (source) => {
const flowFrom = energySourcesByType(this.preferences).grid![0]
.flow_from;
const preferences: EnergyPreferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((src) =>
src.type === "grid"
? {
...src,
flow_from: flowFrom.map((flow) =>
flow === origSource ? source : flow
),
}
: src
),
energy_sources: [...this.preferences.energy_sources, source],
};
await this._savePreferences(preferences);
},
});
}
private _editToSource(ev) {
const origSource: FlowToGridSourceEnergyPreference =
ev.currentTarget.closest(".row").source;
const gridSource = this.preferences.energy_sources.find(
(src) => src.type === "grid"
) as GridSourceTypeEnergyPreference | undefined;
showEnergySettingsGridFlowToDialog(this, {
private _editSource(ev) {
const row = ev.currentTarget.closest(".row");
const origSource: GridSourceTypeEnergyPreference = row.source;
const sourceIndex: number = row.sourceIndex;
showEnergySettingsGridDialog(this, {
source: { ...origSource },
grid_source: gridSource,
metadata: this.statsMetadata?.[origSource.stat_energy_to],
saveCallback: async (source) => {
const flowTo = energySourcesByType(this.preferences).grid![0].flow_to;
grid_sources: this._getGridSources(),
saveCallback: async (newSource) => {
const nonGridSources = this.preferences.energy_sources.filter(
(src) => src.type !== "grid"
);
const updatedGrids = this._getGridSources().map((src, idx) =>
idx === sourceIndex ? newSource : src
);
const preferences: EnergyPreferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((src) =>
src.type === "grid"
? {
...src,
flow_to: flowTo.map((flow) =>
flow === origSource ? source : flow
),
}
: src
),
energy_sources: [...nonGridSources, ...updatedGrids],
};
await this._savePreferences(preferences);
},
});
}
private async _deleteFromSource(ev) {
const sourceToDelete: FlowFromGridSourceEnergyPreference =
ev.currentTarget.closest(".row").source;
private async _deleteSource(ev) {
const row = ev.currentTarget.closest(".row");
const sourceIndex: number = row.sourceIndex;
if (
!(await showConfirmationDialog(this, {
@@ -512,166 +312,18 @@ export class EnergyGridSettings extends LitElement {
return;
}
const flowFrom = energySourcesByType(
this.preferences
).grid![0].flow_from.filter((flow) => flow !== sourceToDelete);
const nonGridSources = this.preferences.energy_sources.filter(
(src) => src.type !== "grid"
);
const updatedGrids = this._getGridSources().filter(
(_, idx) => idx !== sourceIndex
);
const preferences: EnergyPreferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((source) =>
source.type === "grid" ? { ...source, flow_from: flowFrom } : source
),
energy_sources: [...nonGridSources, ...updatedGrids],
};
const cleanedPreferences = this._removeEmptySources(preferences);
await this._savePreferences(cleanedPreferences);
}
private async _deleteToSource(ev) {
const sourceToDelete: FlowToGridSourceEnergyPreference =
ev.currentTarget.closest(".row").source;
if (
!(await showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.config.energy.delete_source"),
}))
) {
return;
}
const flowTo = energySourcesByType(
this.preferences
).grid![0].flow_to.filter((flow) => flow !== sourceToDelete);
const preferences: EnergyPreferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((source) =>
source.type === "grid" ? { ...source, flow_to: flowTo } : source
),
};
const cleanedPreferences = this._removeEmptySources(preferences);
await this._savePreferences(cleanedPreferences);
}
private _addPowerSource() {
const gridSource = this.preferences.energy_sources.find(
(src) => src.type === "grid"
) as GridSourceTypeEnergyPreference | undefined;
showEnergySettingsGridPowerDialog(this, {
grid_source: gridSource,
saveCallback: async (power: GridPowerSourceInput) => {
let preferences: EnergyPreferences;
if (!gridSource) {
preferences = {
...this.preferences,
energy_sources: [
...this.preferences.energy_sources,
{
...emptyGridSourceEnergyPreference(),
power: [power as GridPowerSourceEnergyPreference],
},
],
};
} else {
preferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((src) =>
src.type === "grid"
? {
...src,
power: [
...(gridSource.power || []),
power as GridPowerSourceEnergyPreference,
],
}
: src
),
};
}
await this._savePreferences(preferences);
},
});
}
private _editPowerSource(ev) {
const origSource: GridPowerSourceEnergyPreference =
ev.currentTarget.closest(".row").source;
const gridSource = this.preferences.energy_sources.find(
(src) => src.type === "grid"
) as GridSourceTypeEnergyPreference | undefined;
showEnergySettingsGridPowerDialog(this, {
source: { ...origSource },
grid_source: gridSource,
saveCallback: async (source: GridPowerSourceInput) => {
const power =
energySourcesByType(this.preferences).grid![0].power || [];
const preferences: EnergyPreferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((src) =>
src.type === "grid"
? {
...src,
power: power.map((p) =>
p === origSource
? (source as GridPowerSourceEnergyPreference)
: p
),
}
: src
),
};
await this._savePreferences(preferences);
},
});
}
private async _deletePowerSource(ev) {
const sourceToDelete: GridPowerSourceEnergyPreference =
ev.currentTarget.closest(".row").source;
if (
!(await showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.config.energy.delete_source"),
}))
) {
return;
}
const power =
energySourcesByType(this.preferences).grid![0].power?.filter(
(p) => p !== sourceToDelete
) || [];
const preferences: EnergyPreferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((source) =>
source.type === "grid" ? { ...source, power } : source
),
};
const cleanedPreferences = this._removeEmptySources(preferences);
await this._savePreferences(cleanedPreferences);
}
private _removeEmptySources(preferences: EnergyPreferences) {
// Check if grid sources became an empty type and remove if so
preferences.energy_sources = preferences.energy_sources.reduce<
EnergySource[]
>((acc, source) => {
if (
source.type !== "grid" ||
source.flow_from.length > 0 ||
source.flow_to.length > 0 ||
(source.power && source.power.length > 0)
) {
acc.push(source);
}
return acc;
}, []);
return preferences;
await this._savePreferences(preferences);
}
private async _savePreferences(preferences: EnergyPreferences) {
@@ -684,7 +336,28 @@ export class EnergyGridSettings extends LitElement {
}
static get styles(): CSSResultGroup {
return [haStyle, energyCardStyles];
return [
haStyle,
energyCardStyles,
css`
.row {
height: 58px;
}
.content {
display: flex;
flex-direction: column;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label.secondary {
color: var(--secondary-text-color);
font-size: 0.9em;
}
`,
];
}
}

View File

@@ -4,11 +4,8 @@ import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-formfield";
import "../../../../components/ha-radio";
import "../../../../components/ha-dialog";
import type { HaRadio } from "../../../../components/ha-radio";
import "../../../../components/ha-dialog-footer";
import type {
BatterySourceTypeEnergyPreference,
PowerConfig,
@@ -21,12 +18,17 @@ import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import "./ha-energy-power-config";
import {
buildPowerExcludeList,
getInitialPowerConfig,
getPowerTypeFromConfig,
type HaEnergyPowerConfig,
type PowerType,
} from "./ha-energy-power-config";
import type { EnergySettingsBatteryDialogParams } from "./show-dialogs-energy";
const energyUnitClasses = ["energy"];
const powerUnitClasses = ["power"];
type PowerType = "none" | "standard" | "inverted" | "two_sensors";
@customElement("dialog-energy-battery-settings")
export class DialogEnergyBatterySettings
@@ -47,8 +49,6 @@ export class DialogEnergyBatterySettings
@state() private _energy_units?: string[];
@state() private _power_units?: string[];
@state() private _error?: string;
private _excludeList?: string[];
@@ -63,34 +63,19 @@ export class DialogEnergyBatterySettings
? { ...params.source }
: emptyBatteryEnergyPreference();
// Initialize power type from existing config
if (params.source?.power_config) {
const pc = params.source.power_config;
this._powerConfig = { ...pc };
if (pc.stat_rate_inverted) {
this._powerType = "inverted";
} else if (pc.stat_rate_from || pc.stat_rate_to) {
this._powerType = "two_sensors";
} else if (pc.stat_rate) {
this._powerType = "standard";
} else {
this._powerType = "none";
}
} else if (params.source?.stat_rate) {
// Legacy format - treat as standard
this._powerType = "standard";
this._powerConfig = { stat_rate: params.source.stat_rate };
} else {
this._powerType = "none";
this._powerConfig = {};
}
// Initialize power type and config from existing source
this._powerType = getPowerTypeFromConfig(
params.source?.power_config,
params.source?.stat_rate
);
this._powerConfig = getInitialPowerConfig(
params.source?.power_config,
params.source?.stat_rate
);
this._energy_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
).units;
this._power_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "power")
).units;
// Build energy exclude list
const allSources: string[] = [];
@@ -104,32 +89,11 @@ export class DialogEnergyBatterySettings
id !== this._source?.stat_energy_to
);
// Build power exclude list
const powerIds: string[] = [];
this._params.battery_sources.forEach((entry) => {
if (entry.stat_rate) powerIds.push(entry.stat_rate);
if (entry.power_config) {
if (entry.power_config.stat_rate)
powerIds.push(entry.power_config.stat_rate);
if (entry.power_config.stat_rate_inverted)
powerIds.push(entry.power_config.stat_rate_inverted);
if (entry.power_config.stat_rate_from)
powerIds.push(entry.power_config.stat_rate_from);
if (entry.power_config.stat_rate_to)
powerIds.push(entry.power_config.stat_rate_to);
}
});
const currentPowerIds = [
this._powerConfig.stat_rate,
this._powerConfig.stat_rate_inverted,
this._powerConfig.stat_rate_from,
this._powerConfig.stat_rate_to,
params.source?.stat_rate,
].filter(Boolean) as string[];
this._excludeListPower = powerIds.filter(
(id) => !currentPowerIds.includes(id)
// Build power exclude list using shared helper
this._excludeListPower = buildPowerExcludeList(
this._params.battery_sources,
this._powerConfig,
params.source?.stat_rate
);
this._open = true;
@@ -206,126 +170,14 @@ export class DialogEnergyBatterySettings
)}
></ha-statistic-picker>
<p class="power-section-label">
${this.hass.localize(
"ui.panel.config.energy.battery.dialog.sensor_type"
)}
</p>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.type_none"
)}
>
<ha-radio
value="none"
name="powerType"
.checked=${this._powerType === "none"}
@change=${this._handlePowerTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.type_standard"
)}
>
<ha-radio
value="standard"
name="powerType"
.checked=${this._powerType === "standard"}
@change=${this._handlePowerTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.type_inverted"
)}
>
<ha-radio
value="inverted"
name="powerType"
.checked=${this._powerType === "inverted"}
@change=${this._handlePowerTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.type_two_sensors"
)}
>
<ha-radio
value="two_sensors"
name="powerType"
.checked=${this._powerType === "two_sensors"}
@change=${this._handlePowerTypeChanged}
></ha-radio>
</ha-formfield>
${this._powerType === "standard"
? html`
<ha-statistic-picker
.hass=${this.hass}
.includeUnitClass=${powerUnitClasses}
.value=${this._powerConfig.stat_rate}
.label=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.power"
)}
.excludeStatistics=${this._excludeListPower}
@value-changed=${this._standardPowerChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.power_helper",
{ unit: this._power_units?.join(", ") || "" }
)}
></ha-statistic-picker>
`
: nothing}
${this._powerType === "inverted"
? html`
<ha-statistic-picker
.hass=${this.hass}
.includeUnitClass=${powerUnitClasses}
.value=${this._powerConfig.stat_rate_inverted}
.label=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.power"
)}
.excludeStatistics=${this._excludeListPower}
@value-changed=${this._invertedPowerChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.type_inverted_description"
)}
></ha-statistic-picker>
`
: nothing}
${this._powerType === "two_sensors"
? html`
<ha-statistic-picker
.hass=${this.hass}
.includeUnitClass=${powerUnitClasses}
.value=${this._powerConfig.stat_rate_from}
.label=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.power_discharge"
)}
.excludeStatistics=${[
...(this._excludeListPower || []),
this._powerConfig.stat_rate_to,
].filter((id): id is string => Boolean(id))}
@value-changed=${this._dischargePowerChanged}
></ha-statistic-picker>
<ha-statistic-picker
.hass=${this.hass}
.includeUnitClass=${powerUnitClasses}
.value=${this._powerConfig.stat_rate_to}
.label=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.power_charge"
)}
.excludeStatistics=${[
...(this._excludeListPower || []),
this._powerConfig.stat_rate_from,
].filter((id): id is string => Boolean(id))}
@value-changed=${this._chargePowerChanged}
></ha-statistic-picker>
`
: nothing}
<ha-energy-power-config
.hass=${this.hass}
.powerType=${this._powerType}
.powerConfig=${this._powerConfig}
.excludeList=${this._excludeListPower}
localizeBaseKey="ui.panel.config.energy.battery.dialog"
@power-config-changed=${this._handlePowerConfigChanged}
></ha-energy-power-config>
<ha-dialog-footer slot="footer">
<ha-button
@@ -353,21 +205,15 @@ export class DialogEnergyBatterySettings
return false;
}
// Power fields depend on selected type
switch (this._powerType) {
case "none":
return true;
case "standard":
return !!this._powerConfig.stat_rate;
case "inverted":
return !!this._powerConfig.stat_rate_inverted;
case "two_sensors":
return (
!!this._powerConfig.stat_rate_from && !!this._powerConfig.stat_rate_to
);
default:
return false;
// Check power config validity
const powerConfigEl = this.shadowRoot?.querySelector(
"ha-energy-power-config"
) as HaEnergyPowerConfig | null;
if (powerConfigEl && !powerConfigEl.isValid()) {
return false;
}
return true;
}
private _statisticToChanged(ev: ValueChangedEvent<string>) {
@@ -378,37 +224,11 @@ export class DialogEnergyBatterySettings
this._source = { ...this._source!, stat_energy_from: ev.detail.value };
}
private _handlePowerTypeChanged(ev: Event) {
const input = ev.currentTarget as HaRadio;
this._powerType = input.value as PowerType;
// Clear power config when switching types
this._powerConfig = {};
}
private _standardPowerChanged(ev: ValueChangedEvent<string>) {
this._powerConfig = {
stat_rate: ev.detail.value,
};
}
private _invertedPowerChanged(ev: ValueChangedEvent<string>) {
this._powerConfig = {
stat_rate_inverted: ev.detail.value,
};
}
private _dischargePowerChanged(ev: ValueChangedEvent<string>) {
this._powerConfig = {
...this._powerConfig,
stat_rate_from: ev.detail.value,
};
}
private _chargePowerChanged(ev: ValueChangedEvent<string>) {
this._powerConfig = {
...this._powerConfig,
stat_rate_to: ev.detail.value,
};
private _handlePowerConfigChanged(
ev: CustomEvent<{ powerType: PowerType; powerConfig: PowerConfig }>
) {
this._powerType = ev.detail.powerType;
this._powerConfig = ev.detail.powerConfig;
}
private async _save() {
@@ -443,13 +263,6 @@ export class DialogEnergyBatterySettings
ha-statistic-picker:last-of-type {
margin-bottom: 0;
}
ha-formfield {
display: block;
}
.power-section-label {
margin-top: var(--ha-space-4);
margin-bottom: var(--ha-space-2);
}
`,
];
}

View File

@@ -1,392 +0,0 @@
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 "../../../../components/entity/ha-entity-picker";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-formfield";
import "../../../../components/ha-radio";
import "../../../../components/ha-markdown";
import "../../../../components/ha-dialog";
import type { HaRadio } from "../../../../components/ha-radio";
import type {
FlowFromGridSourceEnergyPreference,
FlowToGridSourceEnergyPreference,
} from "../../../../data/energy";
import {
emptyFlowFromGridSourceEnergyPreference,
emptyFlowToGridSourceEnergyPreference,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import { isExternalStatistic } from "../../../../data/recorder";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { EnergySettingsGridFlowDialogParams } from "./show-dialogs-energy";
const energyUnitClasses = ["energy"];
@customElement("dialog-energy-grid-flow-settings")
export class DialogEnergyGridFlowSettings
extends LitElement
implements HassDialog<EnergySettingsGridFlowDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: EnergySettingsGridFlowDialogParams;
@state() private _open = false;
@state() private _source?:
| FlowFromGridSourceEnergyPreference
| FlowToGridSourceEnergyPreference;
@state() private _costs?: "no-costs" | "number" | "entity" | "statistic";
@state() private _energy_units?: string[];
@state() private _error?: string;
private _excludeList?: string[];
public async showDialog(
params: EnergySettingsGridFlowDialogParams
): Promise<void> {
this._params = params;
this._source = params.source
? { ...params.source }
: params.direction === "from"
? emptyFlowFromGridSourceEnergyPreference()
: emptyFlowToGridSourceEnergyPreference();
this._costs = this._source.entity_energy_price
? "entity"
: this._source.number_energy_price
? "number"
: this._source[
params.direction === "from" ? "stat_cost" : "stat_compensation"
]
? "statistic"
: "no-costs";
const initialSourceId =
this._source[
this._params.direction === "from"
? "stat_energy_from"
: "stat_energy_to"
];
this._energy_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
).units;
this._excludeList = [
...(this._params.grid_source?.flow_from?.map(
(entry) => entry.stat_energy_from
) || []),
...(this._params.grid_source?.flow_to?.map(
(entry) => entry.stat_energy_to
) || []),
].filter((id) => id !== initialSourceId);
this._open = true;
}
public closeDialog() {
this._open = false;
return true;
}
private _dialogClosed() {
this._params = undefined;
this._source = undefined;
this._error = undefined;
this._excludeList = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params || !this._source) {
return nothing;
}
const unitPriceFixed = `${this.hass.config.currency}/kWh`;
const externalSource =
this._source[
this._params.direction === "from"
? "stat_energy_from"
: "stat_energy_to"
] &&
isExternalStatistic(
this._source[
this._params.direction === "from"
? "stat_energy_from"
: "stat_energy_to"
]
);
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.header`
)}
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
<p>
${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.paragraph`
)}
</p>
<ha-statistic-picker
.hass=${this.hass}
.helpMissingEntityUrl=${energyStatisticHelpUrl}
.includeUnitClass=${energyUnitClasses}
.value=${this._source[
this._params.direction === "from"
? "stat_energy_from"
: "stat_energy_to"
]}
.label=${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.energy_stat`
)}
.excludeStatistics=${this._excludeList}
@value-changed=${this._statisticChanged}
.helper=${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.entity_para`,
{ unit: this._energy_units?.join(", ") || "" }
)}
autofocus
></ha-statistic-picker>
<p>
${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_para`
)}
</p>
<ha-formfield
.label=${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.no_cost`
)}
>
<ha-radio
value="no-costs"
name="costs"
.checked=${this._costs === "no-costs"}
@change=${this._handleCostChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_stat`
)}
>
<ha-radio
value="statistic"
name="costs"
.checked=${this._costs === "statistic"}
@change=${this._handleCostChanged}
></ha-radio>
</ha-formfield>
${this._costs === "statistic"
? html`<ha-statistic-picker
class="price-options"
.hass=${this.hass}
statistic-types="sum"
.value=${this._source[
this._params!.direction === "from"
? "stat_cost"
: "stat_compensation"
]}
.label=${`${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_stat_input`
)} (${this.hass.config.currency})`}
@value-changed=${this._priceStatChanged}
></ha-statistic-picker>`
: ""}
<ha-formfield
.label=${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_entity`
)}
>
<ha-radio
value="entity"
name="costs"
.checked=${this._costs === "entity"}
.disabled=${externalSource}
@change=${this._handleCostChanged}
></ha-radio>
</ha-formfield>
${this._costs === "entity"
? html`<ha-entity-picker
class="price-options"
.hass=${this.hass}
include-domains='["sensor", "input_number"]'
.value=${this._source.entity_energy_price}
.label=${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_entity_input`
)}
.helper=${html`<ha-markdown
.content=${this.hass.localize(
"ui.panel.config.energy.grid.flow_dialog.cost_entity_helper",
{ currency: this.hass.config.currency }
)}
></ha-markdown>`}
@value-changed=${this._priceEntityChanged}
></ha-entity-picker>`
: ""}
<ha-formfield
.label=${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_number`
)}
>
<ha-radio
value="number"
name="costs"
.checked=${this._costs === "number"}
.disabled=${externalSource}
@change=${this._handleCostChanged}
></ha-radio>
</ha-formfield>
${this._costs === "number"
? html`<ha-textfield
.label=${`${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_number_input`
)} (${unitPriceFixed})`}
class="price-options"
step="any"
type="number"
.value=${this._source.number_energy_price}
.suffix=${unitPriceFixed}
@change=${this._numberPriceChanged}
>
</ha-textfield>`
: ""}
<ha-dialog-footer slot="footer">
<ha-button
appearance="plain"
@click=${this.closeDialog}
slot="secondaryAction"
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
@click=${this._save}
.disabled=${!this._source[
this._params!.direction === "from"
? "stat_energy_from"
: "stat_energy_to"
]}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _handleCostChanged(ev: CustomEvent) {
const input = ev.currentTarget as HaRadio;
this._costs = input.value as any;
}
private set _costStat(value: null | string) {
this._source![
this._params!.direction === "from" ? "stat_cost" : "stat_compensation"
] = value;
}
private _numberPriceChanged(ev: CustomEvent) {
this._costStat = null;
this._source = {
...this._source!,
number_energy_price: Number((ev.target as any).value),
entity_energy_price: null,
};
}
private _priceStatChanged(ev: CustomEvent) {
this._costStat = ev.detail.value;
this._source = {
...this._source!,
entity_energy_price: null,
number_energy_price: null,
};
}
private _priceEntityChanged(ev: CustomEvent) {
this._costStat = null;
this._source = {
...this._source!,
entity_energy_price: ev.detail.value,
number_energy_price: null,
};
}
private async _statisticChanged(ev: ValueChangedEvent<string>) {
if (
ev.detail.value &&
isExternalStatistic(ev.detail.value) &&
this._costs !== "statistic"
) {
this._costs = "no-costs";
}
this._source = {
...this._source!,
[this._params!.direction === "from"
? "stat_energy_from"
: "stat_energy_to"]: ev.detail.value,
};
}
private async _save() {
try {
if (this._costs === "no-costs") {
this._source!.entity_energy_price = null;
this._source!.number_energy_price = null;
this._costStat = null;
}
await this._params!.saveCallback(this._source!);
this.closeDialog();
} catch (err: any) {
this._error = err.message;
}
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-statistic-picker {
display: block;
margin: var(--ha-space-4) 0;
}
ha-formfield {
display: block;
}
.price-options {
display: block;
padding-left: 52px;
padding-inline-start: 52px;
padding-inline-end: initial;
margin-top: -8px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-energy-grid-flow-settings": DialogEnergyGridFlowSettings;
}
}

View File

@@ -1,358 +0,0 @@
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 "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-formfield";
import "../../../../components/ha-radio";
import "../../../../components/ha-dialog";
import type { HaRadio } from "../../../../components/ha-radio";
import type {
GridPowerSourceInput,
PowerConfig,
} from "../../../../data/energy";
import { energyStatisticHelpUrl } from "../../../../data/energy";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { EnergySettingsGridPowerDialogParams } from "./show-dialogs-energy";
const powerUnitClasses = ["power"];
type SensorType = "standard" | "inverted" | "two_sensors";
@customElement("dialog-energy-grid-power-settings")
export class DialogEnergyGridPowerSettings
extends LitElement
implements HassDialog<EnergySettingsGridPowerDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: EnergySettingsGridPowerDialogParams;
@state() private _open = false;
@state() private _sensorType: SensorType = "standard";
@state() private _powerConfig: PowerConfig = {};
@state() private _power_units?: string[];
@state() private _error?: string;
private _excludeListPower?: string[];
public async showDialog(
params: EnergySettingsGridPowerDialogParams
): Promise<void> {
this._params = params;
// Initialize from existing source
if (params.source?.power_config) {
const pc = params.source.power_config;
this._powerConfig = { ...pc };
if (pc.stat_rate_inverted) {
this._sensorType = "inverted";
} else if (pc.stat_rate_from || pc.stat_rate_to) {
this._sensorType = "two_sensors";
} else {
this._sensorType = "standard";
}
} else if (params.source?.stat_rate) {
// Legacy format - treat as standard
this._sensorType = "standard";
this._powerConfig = { stat_rate: params.source.stat_rate };
} else {
this._sensorType = "standard";
this._powerConfig = {};
}
this._power_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "power")
).units;
// Build exclude list from all power sources
const excludeIds: string[] = [];
this._params.grid_source?.power?.forEach((entry) => {
if (entry.stat_rate) excludeIds.push(entry.stat_rate);
if (entry.power_config) {
if (entry.power_config.stat_rate)
excludeIds.push(entry.power_config.stat_rate);
if (entry.power_config.stat_rate_inverted)
excludeIds.push(entry.power_config.stat_rate_inverted);
if (entry.power_config.stat_rate_from)
excludeIds.push(entry.power_config.stat_rate_from);
if (entry.power_config.stat_rate_to)
excludeIds.push(entry.power_config.stat_rate_to);
}
});
// Filter out current source's IDs
const currentIds = [
this._powerConfig.stat_rate,
this._powerConfig.stat_rate_inverted,
this._powerConfig.stat_rate_from,
this._powerConfig.stat_rate_to,
params.source?.stat_rate,
].filter(Boolean) as string[];
this._excludeListPower = excludeIds.filter(
(id) => !currentIds.includes(id)
);
this._open = true;
}
public closeDialog() {
this._open = false;
return true;
}
private _dialogClosed() {
this._params = undefined;
this._powerConfig = {};
this._sensorType = "standard";
this._error = undefined;
this._excludeListPower = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.energy.grid.power_dialog.header"
)}
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : nothing}
<p>
${this.hass.localize(
"ui.panel.config.energy.grid.power_dialog.sensor_type"
)}
</p>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.grid.power_dialog.type_standard"
)}
>
<ha-radio
value="standard"
name="sensorType"
.checked=${this._sensorType === "standard"}
@change=${this._handleSensorTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.grid.power_dialog.type_inverted"
)}
>
<ha-radio
value="inverted"
name="sensorType"
.checked=${this._sensorType === "inverted"}
@change=${this._handleSensorTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.grid.power_dialog.type_two_sensors"
)}
>
<ha-radio
value="two_sensors"
name="sensorType"
.checked=${this._sensorType === "two_sensors"}
@change=${this._handleSensorTypeChanged}
></ha-radio>
</ha-formfield>
${this._sensorType === "standard"
? html`
<ha-statistic-picker
.hass=${this.hass}
.helpMissingEntityUrl=${energyStatisticHelpUrl}
.includeUnitClass=${powerUnitClasses}
.value=${this._powerConfig.stat_rate}
.label=${this.hass.localize(
"ui.panel.config.energy.grid.power_dialog.power_stat"
)}
.excludeStatistics=${this._excludeListPower}
@value-changed=${this._standardStatisticChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.grid.power_dialog.power_helper",
{ unit: this._power_units?.join(", ") || "" }
)}
autofocus
></ha-statistic-picker>
`
: nothing}
${this._sensorType === "inverted"
? html`
<ha-statistic-picker
.hass=${this.hass}
.helpMissingEntityUrl=${energyStatisticHelpUrl}
.includeUnitClass=${powerUnitClasses}
.value=${this._powerConfig.stat_rate_inverted}
.label=${this.hass.localize(
"ui.panel.config.energy.grid.power_dialog.power_stat"
)}
.excludeStatistics=${this._excludeListPower}
@value-changed=${this._invertedStatisticChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.grid.power_dialog.type_inverted_description"
)}
autofocus
></ha-statistic-picker>
`
: nothing}
${this._sensorType === "two_sensors"
? html`
<ha-statistic-picker
.hass=${this.hass}
.helpMissingEntityUrl=${energyStatisticHelpUrl}
.includeUnitClass=${powerUnitClasses}
.value=${this._powerConfig.stat_rate_from}
.label=${this.hass.localize(
"ui.panel.config.energy.grid.power_dialog.power_from_grid"
)}
.excludeStatistics=${[
...(this._excludeListPower || []),
this._powerConfig.stat_rate_to,
].filter((id): id is string => Boolean(id))}
@value-changed=${this._fromStatisticChanged}
autofocus
></ha-statistic-picker>
<ha-statistic-picker
.hass=${this.hass}
.helpMissingEntityUrl=${energyStatisticHelpUrl}
.includeUnitClass=${powerUnitClasses}
.value=${this._powerConfig.stat_rate_to}
.label=${this.hass.localize(
"ui.panel.config.energy.grid.power_dialog.power_to_grid"
)}
.excludeStatistics=${[
...(this._excludeListPower || []),
this._powerConfig.stat_rate_from,
].filter((id): id is string => Boolean(id))}
@value-changed=${this._toStatisticChanged}
></ha-statistic-picker>
`
: nothing}
<ha-dialog-footer slot="footer">
<ha-button
appearance="plain"
@click=${this.closeDialog}
slot="secondaryAction"
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
@click=${this._save}
.disabled=${!this._isValid()}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _isValid(): boolean {
switch (this._sensorType) {
case "standard":
return !!this._powerConfig.stat_rate;
case "inverted":
return !!this._powerConfig.stat_rate_inverted;
case "two_sensors":
return (
!!this._powerConfig.stat_rate_from && !!this._powerConfig.stat_rate_to
);
default:
return false;
}
}
private _handleSensorTypeChanged(ev: Event) {
const input = ev.currentTarget as HaRadio;
this._sensorType = input.value as SensorType;
// Clear config when switching types
this._powerConfig = {};
}
private _standardStatisticChanged(ev: ValueChangedEvent<string>) {
this._powerConfig = {
stat_rate: ev.detail.value,
};
}
private _invertedStatisticChanged(ev: ValueChangedEvent<string>) {
this._powerConfig = {
stat_rate_inverted: ev.detail.value,
};
}
private _fromStatisticChanged(ev: ValueChangedEvent<string>) {
this._powerConfig = {
...this._powerConfig,
stat_rate_from: ev.detail.value,
};
}
private _toStatisticChanged(ev: ValueChangedEvent<string>) {
this._powerConfig = {
...this._powerConfig,
stat_rate_to: ev.detail.value,
};
}
private async _save() {
try {
const source: GridPowerSourceInput = {
power_config: { ...this._powerConfig },
};
await this._params!.saveCallback(source);
this.closeDialog();
} catch (err: any) {
this._error = err.message;
}
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-formfield {
display: block;
}
ha-statistic-picker {
display: block;
margin-top: var(--ha-space-4);
}
p {
margin-bottom: var(--ha-space-2);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-energy-grid-power-settings": DialogEnergyGridPowerSettings;
}
}

View File

@@ -0,0 +1,662 @@
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 "../../../../components/entity/ha-entity-picker";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-formfield";
import "../../../../components/ha-radio";
import "../../../../components/ha-textfield";
import "../../../../components/ha-dialog";
import type { HaRadio } from "../../../../components/ha-radio";
import type {
GridSourceTypeEnergyPreference,
PowerConfig,
} from "../../../../data/energy";
import {
emptyGridSourceEnergyPreference,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import { isExternalStatistic } from "../../../../data/recorder";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import "./ha-energy-power-config";
import {
buildPowerExcludeList,
getInitialPowerConfig,
getPowerTypeFromConfig,
type HaEnergyPowerConfig,
type PowerType,
} from "./ha-energy-power-config";
import type { EnergySettingsGridDialogParams } from "./show-dialogs-energy";
const energyUnitClasses = ["energy"];
type CostType = "no_cost" | "stat" | "entity" | "number";
@customElement("dialog-energy-grid-settings")
export class DialogEnergyGridSettings
extends LitElement
implements HassDialog<EnergySettingsGridDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: EnergySettingsGridDialogParams;
@state() private _open = false;
@state() private _source?: GridSourceTypeEnergyPreference;
@state() private _powerType: PowerType = "none";
@state() private _powerConfig: PowerConfig = {};
@state() private _importCostType: CostType = "no_cost";
@state() private _exportCostType: CostType = "no_cost";
@state() private _energy_units?: string[];
@state() private _error?: string;
private _excludeList?: string[];
private _excludeListPower?: string[];
public async showDialog(
params: EnergySettingsGridDialogParams
): Promise<void> {
this._params = params;
this._source = params.source
? { ...params.source }
: emptyGridSourceEnergyPreference();
// Initialize power type and config from existing source
this._powerType = getPowerTypeFromConfig(
params.source?.power_config,
params.source?.stat_rate
);
this._powerConfig = getInitialPowerConfig(
params.source?.power_config,
params.source?.stat_rate
);
// Initialize import cost type
if (params.source?.stat_cost) {
this._importCostType = "stat";
} else if (params.source?.entity_energy_price) {
this._importCostType = "entity";
} else if (
params.source?.number_energy_price !== null &&
params.source?.number_energy_price !== undefined
) {
this._importCostType = "number";
} else {
this._importCostType = "no_cost";
}
// Initialize export cost type
if (params.source?.stat_compensation) {
this._exportCostType = "stat";
} else if (params.source?.entity_energy_price_export) {
this._exportCostType = "entity";
} else if (
params.source?.number_energy_price_export !== null &&
params.source?.number_energy_price_export !== undefined
) {
this._exportCostType = "number";
} else {
this._exportCostType = "no_cost";
}
this._energy_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
).units;
// Build energy exclude list
const allSources: string[] = [];
this._params.grid_sources.forEach((entry) => {
if (entry.stat_energy_from) allSources.push(entry.stat_energy_from);
if (entry.stat_energy_to) allSources.push(entry.stat_energy_to);
});
this._excludeList = allSources.filter(
(id) =>
id !== this._source?.stat_energy_from &&
id !== this._source?.stat_energy_to
);
// Build power exclude list using shared helper
this._excludeListPower = buildPowerExcludeList(
this._params.grid_sources,
this._powerConfig,
params.source?.stat_rate
);
this._open = true;
}
public closeDialog() {
this._open = false;
return true;
}
private _dialogClosed() {
this._params = undefined;
this._source = undefined;
this._powerType = "none";
this._powerConfig = {};
this._importCostType = "no_cost";
this._exportCostType = "no_cost";
this._error = undefined;
this._excludeList = undefined;
this._excludeListPower = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params || !this._source) {
return nothing;
}
const hasExport = !!this._source.stat_energy_to;
// External statistics (from integrations) cannot use entity/number cost tracking
const externalImportSource =
this._source.stat_energy_from &&
isExternalStatistic(this._source.stat_energy_from);
const externalExportSource =
this._source.stat_energy_to &&
isExternalStatistic(this._source.stat_energy_to);
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.header"
)}
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : nothing}
<ha-statistic-picker
.hass=${this.hass}
.helpMissingEntityUrl=${energyStatisticHelpUrl}
.includeUnitClass=${energyUnitClasses}
.value=${this._source.stat_energy_from}
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.energy_from_grid"
)}
.excludeStatistics=${[
...(this._excludeList || []),
this._source.stat_energy_to,
].filter((id): id is string => Boolean(id))}
@value-changed=${this._statisticFromChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.energy_from_helper",
{ unit: this._energy_units?.join(", ") || "" }
)}
autofocus
></ha-statistic-picker>
<ha-statistic-picker
.hass=${this.hass}
.helpMissingEntityUrl=${energyStatisticHelpUrl}
.includeUnitClass=${energyUnitClasses}
.value=${this._source.stat_energy_to}
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.energy_to_grid"
)}
.excludeStatistics=${[
...(this._excludeList || []),
this._source.stat_energy_from,
].filter((id): id is string => Boolean(id))}
@value-changed=${this._statisticToChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.energy_to_helper",
{ unit: this._energy_units?.join(", ") || "" }
)}
></ha-statistic-picker>
<p class="section-label">
${this.hass.localize(
"ui.panel.config.energy.grid.dialog.import_cost"
)}
</p>
<p class="section-description">
${this.hass.localize(
"ui.panel.config.energy.grid.dialog.import_cost_para"
)}
</p>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.no_cost_tracking"
)}
>
<ha-radio
value="no_cost"
name="importCostType"
.checked=${this._importCostType === "no_cost"}
@change=${this._handleImportCostTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.cost_stat"
)}
>
<ha-radio
value="stat"
name="importCostType"
.checked=${this._importCostType === "stat"}
@change=${this._handleImportCostTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.cost_entity"
)}
>
<ha-radio
value="entity"
name="importCostType"
.checked=${this._importCostType === "entity"}
.disabled=${externalImportSource}
@change=${this._handleImportCostTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.cost_number"
)}
>
<ha-radio
value="number"
name="importCostType"
.checked=${this._importCostType === "number"}
.disabled=${externalImportSource}
@change=${this._handleImportCostTypeChanged}
></ha-radio>
</ha-formfield>
${this._importCostType === "stat"
? html`
<ha-statistic-picker
.hass=${this.hass}
.value=${this._source.stat_cost}
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.cost_stat_label"
)}
@value-changed=${this._statCostChanged}
></ha-statistic-picker>
`
: nothing}
${this._importCostType === "entity"
? html`
<ha-entity-picker
.hass=${this.hass}
.value=${this._source.entity_energy_price}
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.cost_entity_label"
)}
include-domains='["sensor", "input_number"]'
@value-changed=${this._entityCostChanged}
></ha-entity-picker>
`
: nothing}
${this._importCostType === "number"
? html`
<ha-textfield
.value=${this._source.number_energy_price ?? ""}
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.cost_number_label"
)}
type="number"
step="any"
@input=${this._numberCostChanged}
.suffix=${`${this.hass.config.currency}/kWh`}
></ha-textfield>
`
: nothing}
${hasExport
? html`
<p class="section-label">
${this.hass.localize(
"ui.panel.config.energy.grid.dialog.export_compensation"
)}
</p>
<p class="section-description">
${this.hass.localize(
"ui.panel.config.energy.grid.dialog.export_compensation_para"
)}
</p>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.no_compensation_tracking"
)}
>
<ha-radio
value="no_cost"
name="exportCostType"
.checked=${this._exportCostType === "no_cost"}
@change=${this._handleExportCostTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.compensation_stat"
)}
>
<ha-radio
value="stat"
name="exportCostType"
.checked=${this._exportCostType === "stat"}
@change=${this._handleExportCostTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.compensation_entity"
)}
>
<ha-radio
value="entity"
name="exportCostType"
.checked=${this._exportCostType === "entity"}
.disabled=${externalExportSource}
@change=${this._handleExportCostTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.compensation_number"
)}
>
<ha-radio
value="number"
name="exportCostType"
.checked=${this._exportCostType === "number"}
.disabled=${externalExportSource}
@change=${this._handleExportCostTypeChanged}
></ha-radio>
</ha-formfield>
${this._exportCostType === "stat"
? html`
<ha-statistic-picker
.hass=${this.hass}
.value=${this._source.stat_compensation}
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.compensation_stat_label"
)}
@value-changed=${this._statCompensationChanged}
></ha-statistic-picker>
`
: nothing}
${this._exportCostType === "entity"
? html`
<ha-entity-picker
.hass=${this.hass}
.value=${this._source.entity_energy_price_export}
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.compensation_entity_label"
)}
include-domains='["sensor", "input_number"]'
@value-changed=${this._entityCompensationChanged}
></ha-entity-picker>
`
: nothing}
${this._exportCostType === "number"
? html`
<ha-textfield
.value=${this._source.number_energy_price_export ?? ""}
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.compensation_number_label"
)}
type="number"
step="any"
@input=${this._numberCompensationChanged}
.suffix=${`${this.hass.config.currency}/kWh`}
></ha-textfield>
`
: nothing}
`
: nothing}
<ha-energy-power-config
.hass=${this.hass}
.powerType=${this._powerType}
.powerConfig=${this._powerConfig}
.excludeList=${this._excludeListPower}
localizeBaseKey="ui.panel.config.energy.grid.dialog"
@power-config-changed=${this._handlePowerConfigChanged}
></ha-energy-power-config>
<ha-dialog-footer slot="footer">
<ha-button
appearance="plain"
@click=${this.closeDialog}
slot="secondaryAction"
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
@click=${this._save}
.disabled=${!this._isValid()}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _isValid(): boolean {
// Grid must have at least one of: import, export, or power
const hasImport = !!this._source?.stat_energy_from;
const hasExport = !!this._source?.stat_energy_to;
const hasPower = this._powerType !== "none";
if (!hasImport && !hasExport && !hasPower) {
return false;
}
// Check power config validity (if power is configured)
if (hasPower) {
const powerConfigEl = this.shadowRoot?.querySelector(
"ha-energy-power-config"
) as HaEnergyPowerConfig | null;
if (powerConfigEl && !powerConfigEl.isValid()) {
return false;
}
}
return true;
}
private _statisticFromChanged(ev: ValueChangedEvent<string>) {
this._source = { ...this._source!, stat_energy_from: ev.detail.value };
// Reset cost type if switching to external statistic with incompatible cost type
if (
ev.detail.value &&
isExternalStatistic(ev.detail.value) &&
(this._importCostType === "entity" || this._importCostType === "number")
) {
this._importCostType = "no_cost";
this._source = {
...this._source!,
entity_energy_price: null,
number_energy_price: null,
};
}
}
private _statisticToChanged(ev: ValueChangedEvent<string>) {
this._source = {
...this._source!,
stat_energy_to: ev.detail.value || null,
};
// Clear export cost if export is removed
if (!ev.detail.value) {
this._exportCostType = "no_cost";
this._source = {
...this._source!,
stat_compensation: null,
entity_energy_price_export: null,
number_energy_price_export: null,
};
} else if (
// Reset cost type if switching to external statistic with incompatible cost type
isExternalStatistic(ev.detail.value) &&
(this._exportCostType === "entity" || this._exportCostType === "number")
) {
this._exportCostType = "no_cost";
this._source = {
...this._source!,
entity_energy_price_export: null,
number_energy_price_export: null,
};
}
}
private _handleImportCostTypeChanged(ev: Event) {
const input = ev.currentTarget as HaRadio;
this._importCostType = input.value as CostType;
// Clear other cost fields when switching types
this._source = {
...this._source!,
stat_cost: null,
entity_energy_price: null,
number_energy_price: null,
};
}
private _handleExportCostTypeChanged(ev: Event) {
const input = ev.currentTarget as HaRadio;
this._exportCostType = input.value as CostType;
// Clear other cost fields when switching types
this._source = {
...this._source!,
stat_compensation: null,
entity_energy_price_export: null,
number_energy_price_export: null,
};
}
private _statCostChanged(ev: ValueChangedEvent<string>) {
this._source = { ...this._source!, stat_cost: ev.detail.value || null };
}
private _entityCostChanged(ev: ValueChangedEvent<string>) {
this._source = {
...this._source!,
entity_energy_price: ev.detail.value || null,
};
}
private _numberCostChanged(ev: Event) {
const input = ev.currentTarget as HTMLInputElement;
const value = input.value ? parseFloat(input.value) : null;
this._source = { ...this._source!, number_energy_price: value };
}
private _statCompensationChanged(ev: ValueChangedEvent<string>) {
this._source = {
...this._source!,
stat_compensation: ev.detail.value || null,
};
}
private _entityCompensationChanged(ev: ValueChangedEvent<string>) {
this._source = {
...this._source!,
entity_energy_price_export: ev.detail.value || null,
};
}
private _numberCompensationChanged(ev: Event) {
const input = ev.currentTarget as HTMLInputElement;
const value = input.value ? parseFloat(input.value) : null;
this._source = { ...this._source!, number_energy_price_export: value };
}
private _handlePowerConfigChanged(
ev: CustomEvent<{ powerType: PowerType; powerConfig: PowerConfig }>
) {
this._powerType = ev.detail.powerType;
this._powerConfig = ev.detail.powerConfig;
}
private async _save() {
try {
const source: GridSourceTypeEnergyPreference = {
type: "grid",
stat_energy_from: this._source!.stat_energy_from,
stat_energy_to: this._source!.stat_energy_to,
stat_cost: this._source!.stat_cost,
stat_compensation: this._source!.stat_compensation,
entity_energy_price: this._source!.entity_energy_price,
number_energy_price: this._source!.number_energy_price,
entity_energy_price_export: this._source!.entity_energy_price_export,
number_energy_price_export: this._source!.number_energy_price_export,
cost_adjustment_day: this._source!.cost_adjustment_day,
};
// Only include power_config if a power type is selected
if (this._powerType !== "none") {
source.power_config = { ...this._powerConfig };
}
await this._params!.saveCallback(source);
this.closeDialog();
} catch (err: any) {
this._error = err.message;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
ha-statistic-picker,
ha-entity-picker,
ha-textfield {
display: block;
margin-bottom: var(--ha-space-4);
}
ha-statistic-picker:last-of-type,
ha-entity-picker:last-of-type,
ha-textfield:last-of-type {
margin-bottom: 0;
}
ha-formfield {
display: block;
}
.section-label {
margin-top: var(--ha-space-4);
margin-bottom: var(--ha-space-2);
}
.section-description {
margin-top: 0;
margin-bottom: var(--ha-space-2);
color: var(--secondary-text-color);
font-size: 0.875em;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-energy-grid-settings": DialogEnergyGridSettings;
}
}

View File

@@ -0,0 +1,351 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-formfield";
import "../../../../components/ha-radio";
import type { HaRadio } from "../../../../components/ha-radio";
import type { PowerConfig } from "../../../../data/energy";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
export type PowerType = "none" | "standard" | "inverted" | "two_sensors";
const powerUnitClasses = ["power"];
/**
* Extracts the power type from a PowerConfig object.
*/
export function getPowerTypeFromConfig(
powerConfig?: PowerConfig,
statRate?: string
): PowerType {
if (powerConfig) {
if (powerConfig.stat_rate_inverted) {
return "inverted";
}
if (powerConfig.stat_rate_from || powerConfig.stat_rate_to) {
return "two_sensors";
}
if (powerConfig.stat_rate) {
return "standard";
}
} else if (statRate) {
// Legacy format - treat as standard
return "standard";
}
return "none";
}
/**
* Creates an initial PowerConfig from existing config or legacy stat_rate.
*/
export function getInitialPowerConfig(
powerConfig?: PowerConfig,
statRate?: string
): PowerConfig {
if (powerConfig) {
return { ...powerConfig };
}
if (statRate) {
return { stat_rate: statRate };
}
return {};
}
/**
* Builds an exclude list for power statistics from existing sources.
*/
export function buildPowerExcludeList(
sources: { stat_rate?: string; power_config?: PowerConfig }[],
currentPowerConfig: PowerConfig,
currentStatRate?: string
): string[] {
const powerIds: string[] = [];
sources.forEach((entry) => {
if (entry.stat_rate) powerIds.push(entry.stat_rate);
if (entry.power_config) {
if (entry.power_config.stat_rate)
powerIds.push(entry.power_config.stat_rate);
if (entry.power_config.stat_rate_inverted)
powerIds.push(entry.power_config.stat_rate_inverted);
if (entry.power_config.stat_rate_from)
powerIds.push(entry.power_config.stat_rate_from);
if (entry.power_config.stat_rate_to)
powerIds.push(entry.power_config.stat_rate_to);
}
});
const currentPowerIds = [
currentPowerConfig.stat_rate,
currentPowerConfig.stat_rate_inverted,
currentPowerConfig.stat_rate_from,
currentPowerConfig.stat_rate_to,
currentStatRate,
].filter(Boolean) as string[];
return powerIds.filter((id) => !currentPowerIds.includes(id));
}
declare global {
interface HASSDomEvents {
"power-config-changed": { powerType: PowerType; powerConfig: PowerConfig };
}
}
@customElement("ha-energy-power-config")
export class HaEnergyPowerConfig extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public powerType: PowerType = "none";
@property({ attribute: false }) public powerConfig: PowerConfig = {};
@property({ attribute: false }) public excludeList?: string[];
/**
* Base key for localization lookups.
* Should include keys for: sensor_type, type_none, type_standard, type_inverted,
* type_two_sensors, power, power_helper, type_inverted_description, power_from, power_to
*/
@property({ attribute: false }) public localizeBaseKey =
"ui.panel.config.energy.battery.dialog";
@state() private _powerUnits?: string[];
protected async willUpdate(changedProps: PropertyValues): Promise<void> {
super.willUpdate(changedProps);
if (changedProps.has("hass") && !this._powerUnits) {
this._powerUnits = (
await getSensorDeviceClassConvertibleUnits(this.hass, "power")
).units;
}
}
protected render(): TemplateResult {
return html`
<p class="power-section-label">
${this.hass.localize(
`${this.localizeBaseKey}.sensor_type` as LocalizeKeys
)}
</p>
<p class="power-section-description">
${this.hass.localize(
`${this.localizeBaseKey}.sensor_type_para` as LocalizeKeys
)}
</p>
<ha-formfield
.label=${this.hass.localize(
`${this.localizeBaseKey}.type_none` as LocalizeKeys
)}
>
<ha-radio
value="none"
name="powerType"
.checked=${this.powerType === "none"}
@change=${this._handlePowerTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
`${this.localizeBaseKey}.type_standard` as LocalizeKeys
)}
>
<ha-radio
value="standard"
name="powerType"
.checked=${this.powerType === "standard"}
@change=${this._handlePowerTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
`${this.localizeBaseKey}.type_inverted` as LocalizeKeys
)}
>
<ha-radio
value="inverted"
name="powerType"
.checked=${this.powerType === "inverted"}
@change=${this._handlePowerTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
`${this.localizeBaseKey}.type_two_sensors` as LocalizeKeys
)}
>
<ha-radio
value="two_sensors"
name="powerType"
.checked=${this.powerType === "two_sensors"}
@change=${this._handlePowerTypeChanged}
></ha-radio>
</ha-formfield>
${this.powerType === "standard"
? html`
<ha-statistic-picker
.hass=${this.hass}
.includeUnitClass=${powerUnitClasses}
.value=${this.powerConfig.stat_rate}
.label=${this.hass.localize(
`${this.localizeBaseKey}.power` as LocalizeKeys
)}
.excludeStatistics=${this.excludeList}
@value-changed=${this._standardPowerChanged}
.helper=${this.hass.localize(
`${this.localizeBaseKey}.power_helper` as LocalizeKeys,
{ unit: this._powerUnits?.join(", ") || "" }
)}
></ha-statistic-picker>
`
: nothing}
${this.powerType === "inverted"
? html`
<ha-statistic-picker
.hass=${this.hass}
.includeUnitClass=${powerUnitClasses}
.value=${this.powerConfig.stat_rate_inverted}
.label=${this.hass.localize(
`${this.localizeBaseKey}.power` as LocalizeKeys
)}
.excludeStatistics=${this.excludeList}
@value-changed=${this._invertedPowerChanged}
.helper=${this.hass.localize(
`${this.localizeBaseKey}.type_inverted_description` as LocalizeKeys
)}
></ha-statistic-picker>
`
: nothing}
${this.powerType === "two_sensors"
? html`
<ha-statistic-picker
.hass=${this.hass}
.includeUnitClass=${powerUnitClasses}
.value=${this.powerConfig.stat_rate_from}
.label=${this.hass.localize(
`${this.localizeBaseKey}.power_from` as LocalizeKeys
)}
.excludeStatistics=${[
...(this.excludeList || []),
this.powerConfig.stat_rate_to,
].filter((id): id is string => Boolean(id))}
@value-changed=${this._fromPowerChanged}
></ha-statistic-picker>
<ha-statistic-picker
.hass=${this.hass}
.includeUnitClass=${powerUnitClasses}
.value=${this.powerConfig.stat_rate_to}
.label=${this.hass.localize(
`${this.localizeBaseKey}.power_to` as LocalizeKeys
)}
.excludeStatistics=${[
...(this.excludeList || []),
this.powerConfig.stat_rate_from,
].filter((id): id is string => Boolean(id))}
@value-changed=${this._toPowerChanged}
></ha-statistic-picker>
`
: nothing}
`;
}
private _handlePowerTypeChanged(ev: Event) {
const input = ev.currentTarget as HaRadio;
const newPowerType = input.value as PowerType;
// Clear power config when switching types
fireEvent(this, "power-config-changed", {
powerType: newPowerType,
powerConfig: {},
});
}
private _standardPowerChanged(ev: ValueChangedEvent<string>) {
fireEvent(this, "power-config-changed", {
powerType: this.powerType,
powerConfig: { stat_rate: ev.detail.value },
});
}
private _invertedPowerChanged(ev: ValueChangedEvent<string>) {
fireEvent(this, "power-config-changed", {
powerType: this.powerType,
powerConfig: { stat_rate_inverted: ev.detail.value },
});
}
private _fromPowerChanged(ev: ValueChangedEvent<string>) {
fireEvent(this, "power-config-changed", {
powerType: this.powerType,
powerConfig: {
...this.powerConfig,
stat_rate_from: ev.detail.value,
},
});
}
private _toPowerChanged(ev: ValueChangedEvent<string>) {
fireEvent(this, "power-config-changed", {
powerType: this.powerType,
powerConfig: {
...this.powerConfig,
stat_rate_to: ev.detail.value,
},
});
}
/**
* Validates that the power config is complete for the selected type.
*/
public isValid(): boolean {
switch (this.powerType) {
case "none":
return true;
case "standard":
return !!this.powerConfig.stat_rate;
case "inverted":
return !!this.powerConfig.stat_rate_inverted;
case "two_sensors":
return (
!!this.powerConfig.stat_rate_from && !!this.powerConfig.stat_rate_to
);
default:
return false;
}
}
static readonly styles: CSSResultGroup = css`
ha-statistic-picker {
display: block;
margin-bottom: var(--ha-space-4);
}
ha-statistic-picker:last-of-type {
margin-bottom: 0;
}
ha-formfield {
display: block;
}
.power-section-label {
margin-top: var(--ha-space-4);
margin-bottom: var(--ha-space-2);
}
.power-section-description {
margin-top: 0;
margin-bottom: var(--ha-space-2);
color: var(--secondary-text-color);
font-size: 0.875em;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-energy-power-config": HaEnergyPowerConfig;
}
}

View File

@@ -4,49 +4,17 @@ import type {
DeviceConsumptionEnergyPreference,
EnergyGasUnitClass,
EnergyInfo,
FlowFromGridSourceEnergyPreference,
FlowToGridSourceEnergyPreference,
GasSourceTypeEnergyPreference,
GridPowerSourceEnergyPreference,
GridPowerSourceInput,
GridSourceTypeEnergyPreference,
SolarSourceTypeEnergyPreference,
WaterSourceTypeEnergyPreference,
} from "../../../../data/energy";
import type { StatisticsMetaData } from "../../../../data/recorder";
export interface EnergySettingsGridFlowDialogParams {
source?:
| FlowFromGridSourceEnergyPreference
| FlowToGridSourceEnergyPreference;
metadata?: StatisticsMetaData;
direction: "from" | "to";
grid_source?: GridSourceTypeEnergyPreference;
saveCallback: (
source:
| FlowFromGridSourceEnergyPreference
| FlowToGridSourceEnergyPreference
) => Promise<void>;
}
export interface EnergySettingsGridFlowFromDialogParams {
source?: FlowFromGridSourceEnergyPreference;
metadata?: StatisticsMetaData;
grid_source?: GridSourceTypeEnergyPreference;
saveCallback: (source: FlowFromGridSourceEnergyPreference) => Promise<void>;
}
export interface EnergySettingsGridFlowToDialogParams {
source?: FlowToGridSourceEnergyPreference;
metadata?: StatisticsMetaData;
grid_source?: GridSourceTypeEnergyPreference;
saveCallback: (source: FlowToGridSourceEnergyPreference) => Promise<void>;
}
export interface EnergySettingsGridPowerDialogParams {
source?: GridPowerSourceEnergyPreference;
grid_source?: GridSourceTypeEnergyPreference;
saveCallback: (source: GridPowerSourceInput) => Promise<void>;
export interface EnergySettingsGridDialogParams {
source?: GridSourceTypeEnergyPreference;
grid_sources: GridSourceTypeEnergyPreference[];
saveCallback: (source: GridSourceTypeEnergyPreference) => Promise<void>;
}
export interface EnergySettingsSolarDialogParams {
@@ -146,28 +114,6 @@ export const showEnergySettingsWaterDialog = (
});
};
export const showEnergySettingsGridFlowFromDialog = (
element: HTMLElement,
dialogParams: EnergySettingsGridFlowFromDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-energy-grid-flow-settings",
dialogImport: () => import("./dialog-energy-grid-flow-settings"),
dialogParams: { ...dialogParams, direction: "from" },
});
};
export const showEnergySettingsGridFlowToDialog = (
element: HTMLElement,
dialogParams: EnergySettingsGridFlowToDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-energy-grid-flow-settings",
dialogImport: () => import("./dialog-energy-grid-flow-settings"),
dialogParams: { ...dialogParams, direction: "to" },
});
};
export const showEnergySettingsDeviceWaterDialog = (
element: HTMLElement,
dialogParams: EnergySettingsDeviceWaterDialogParams
@@ -179,13 +125,13 @@ export const showEnergySettingsDeviceWaterDialog = (
});
};
export const showEnergySettingsGridPowerDialog = (
export const showEnergySettingsGridDialog = (
element: HTMLElement,
dialogParams: EnergySettingsGridPowerDialogParams
dialogParams: EnergySettingsGridDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-energy-grid-power-settings",
dialogImport: () => import("./dialog-energy-grid-power-settings"),
dialogTag: "dialog-energy-grid-settings",
dialogImport: () => import("./dialog-energy-grid-settings"),
dialogParams: dialogParams,
});
};

View File

@@ -290,12 +290,14 @@ class PanelEnergy extends LitElement {
["grid", "solar", "battery"].includes(source.type)
);
const hasPowerSource = this._prefs.energy_sources.some(
(source) =>
(source.type === "solar" && source.stat_rate) ||
(source.type === "battery" && source.stat_rate) ||
(source.type === "grid" && source.power?.length)
);
const hasPowerSource = this._prefs.energy_sources.some((source) => {
if (source.type === "solar" && source.stat_rate) return true;
if (source.type === "battery" && source.stat_rate) return true;
if (source.type === "grid") {
return !!source.stat_rate || !!source.power_config;
}
return false;
});
const hasDevicePower = this._prefs.device_consumption.some(
(device) => device.stat_rate
@@ -435,26 +437,25 @@ class PanelEnergy extends LitElement {
energy_sources
.filter((s) => s.type === "grid")
.forEach((source) => {
source = source as GridSourceTypeEnergyPreference;
source.flow_from.forEach((flowFrom) => {
const statId = flowFrom.stat_energy_from;
grid_consumptions.push(statId);
const costId =
flowFrom.stat_cost || energyData.state.info.cost_sensors[statId];
if (costId) {
grid_consumptions_cost.push(costId);
const gridSource = source as GridSourceTypeEnergyPreference;
if (gridSource.stat_energy_from) {
grid_consumptions.push(gridSource.stat_energy_from);
const importCostId =
gridSource.stat_cost ||
energyData.state.info.cost_sensors[gridSource.stat_energy_from];
if (importCostId) {
grid_consumptions_cost.push(importCostId);
}
});
source.flow_to.forEach((flowTo) => {
const statId = flowTo.stat_energy_to;
grid_productions.push(statId);
const costId =
flowTo.stat_compensation ||
energyData.state.info.cost_sensors[statId];
if (costId) {
grid_productions_cost.push(costId);
}
if (gridSource.stat_energy_to) {
grid_productions.push(gridSource.stat_energy_to);
const exportCostId =
gridSource.stat_compensation ||
energyData.state.info.cost_sensors[gridSource.stat_energy_to];
if (exportCostId) {
grid_productions_cost.push(exportCostId);
}
});
}
});
printCategory(

View File

@@ -41,10 +41,10 @@ export class EnergyOverviewViewStrategy extends ReactiveElement {
}
const hasGrid = prefs.energy_sources.find(
(source) =>
(source): source is GridSourceTypeEnergyPreference =>
source.type === "grid" &&
(source.flow_from?.length || source.flow_to?.length)
) as GridSourceTypeEnergyPreference;
(!!source.stat_energy_from || !!source.stat_energy_to)
);
const hasGas = prefs.energy_sources.some((source) => source.type === "gas");
const hasBattery = prefs.energy_sources.some(
(source) => source.type === "battery"
@@ -56,12 +56,14 @@ export class EnergyOverviewViewStrategy extends ReactiveElement {
(source) => source.type === "water"
);
const hasWaterDevices = prefs.device_consumption_water?.length;
const hasPowerSources = prefs.energy_sources.find(
(source) =>
(source.type === "solar" && source.stat_rate) ||
(source.type === "battery" && source.stat_rate) ||
(source.type === "grid" && source.power?.length)
);
const hasPowerSources = prefs.energy_sources.find((source) => {
if (source.type === "solar" && source.stat_rate) return true;
if (source.type === "battery" && source.stat_rate) return true;
if (source.type === "grid") {
return !!source.stat_rate || !!source.power_config;
}
return false;
});
if (hasGrid || hasBattery || hasSolar) {
view.sections!.push({

View File

@@ -39,11 +39,11 @@ export class EnergyViewStrategy extends ReactiveElement {
view.type = "sidebar";
const hasGrid = prefs.energy_sources.find(
(source) =>
(source): source is GridSourceTypeEnergyPreference =>
source.type === "grid" &&
(source.flow_from?.length || source.flow_to?.length)
) as GridSourceTypeEnergyPreference;
const hasReturn = hasGrid && hasGrid.flow_to.length;
(!!source.stat_energy_from || !!source.stat_energy_to)
);
const hasReturn = hasGrid && !!hasGrid.stat_energy_to;
const hasSolar = prefs.energy_sources.some(
(source) => source.type === "solar"
);

View File

@@ -28,12 +28,14 @@ export class PowerViewStrategy extends ReactiveElement {
await energyCollection.refresh();
const prefs = energyCollection.prefs;
const hasPowerSources = prefs?.energy_sources.some(
(source) =>
(source.type === "solar" && source.stat_rate) ||
(source.type === "battery" && source.stat_rate) ||
(source.type === "grid" && source.power?.length)
);
const hasPowerSources = prefs?.energy_sources.some((source) => {
if (source.type === "solar" && source.stat_rate) return true;
if (source.type === "battery" && source.stat_rate) return true;
if (source.type === "grid") {
return !!source.stat_rate || !!source.power_config;
}
return false;
});
const hasPowerDevices = prefs?.device_consumption.some(
(device) => device.stat_rate
);

View File

@@ -112,12 +112,13 @@ class HuiEnergyDistrubutionCard
const types = energySourcesByType(prefs);
const hasGrid =
!!types.grid?.[0].flow_from.length || !!types.grid?.[0].flow_to.length;
!!types.grid?.[0] &&
(!!types.grid[0].stat_energy_from || !!types.grid[0].stat_energy_to);
const hasSolarProduction = types.solar !== undefined;
const hasBattery = types.battery !== undefined;
const hasGas = types.gas !== undefined;
const hasWater = types.water !== undefined;
const hasReturnToGrid = !!types.grid?.[0].flow_to.length;
const hasReturnToGrid = !!types.grid?.[0] && !!types.grid[0].stat_energy_to;
const { summedData, compareSummedData: _ } = getSummedData(this._data);
const { consumption, compareConsumption: __ } = computeConsumptionData(

View File

@@ -211,7 +211,7 @@ class HuiEnergySankeyCard
}
// Add grid return if available
if (types.grid && types.grid[0].flow_to) {
if (types.grid && types.grid[0].stat_energy_to) {
const totalToGrid = summedData.total.to_grid ?? 0;
nodes.push({

View File

@@ -278,17 +278,19 @@ export class HuiEnergySourcesTableCard
const computedStyles = getComputedStyle(this);
// Check if any source has cost configuration
const gridHasCosts = types.grid?.some(
(source) =>
source.stat_cost ||
source.entity_energy_price ||
source.number_energy_price ||
source.stat_compensation ||
source.entity_energy_price_export ||
source.number_energy_price_export
);
const showCosts = !!(
types.grid?.[0].flow_from.some(
(flow) =>
flow.stat_cost || flow.entity_energy_price || flow.number_energy_price
) ||
types.grid?.[0].flow_to.some(
(flow) =>
flow.stat_compensation ||
flow.entity_energy_price ||
flow.number_energy_price
) ||
gridHasCosts ||
types.gas?.some(
(flow) =>
flow.stat_cost || flow.entity_energy_price || flow.number_energy_price
@@ -578,103 +580,95 @@ export class HuiEnergySourcesTableCard
: undefined
)
: ""}
${types.grid?.map(
(source) =>
html`${source.flow_from.map((flow, idx) => {
const cost_stat =
flow.stat_cost ||
this._data!.info.cost_sensors[flow.stat_energy_from];
const {
hasData,
energy,
energyCompare,
cost,
costCompare,
} = _extractStatData(
flow.stat_energy_from,
${types.grid?.map((source, idx) => {
const importResult = (() => {
if (!source.stat_energy_from) return nothing;
const cost_stat =
source.stat_cost ||
this._data!.info.cost_sensors[source.stat_energy_from];
const { hasData, energy, energyCompare, cost, costCompare } =
_extractStatData(
source.stat_energy_from,
cost_stat || null
);
if (!hasData && !cost && !costCompare) {
return nothing;
}
if (!hasData && !cost && !costCompare) {
return nothing;
}
totalGrid += energy;
totalGridCompare += energyCompare;
totalGrid += energy;
totalGridCompare += energyCompare;
if (cost_stat) {
hasGridCost = true;
totalGridCost += cost;
totalGridCostCompare += costCompare;
}
if (cost_stat) {
hasGridCost = true;
totalGridCost += cost;
totalGridCostCompare += costCompare;
}
if (showOnlyTotals) {
return nothing;
}
if (showOnlyTotals) {
return nothing;
}
return this._renderRow(
computedStyles,
"grid_consumption",
flow.stat_energy_from,
idx,
energy,
energyCompare,
"kWh",
cost,
costCompare,
showCosts,
compare
);
})}
${source.flow_to.map((flow, idx) => {
const cost_stat =
flow.stat_compensation ||
this._data!.info.cost_sensors[flow.stat_energy_to];
const {
hasData,
energy,
energyCompare,
cost,
costCompare,
} = _extractStatData(
flow.stat_energy_to,
cost_stat || null
);
return this._renderRow(
computedStyles,
"grid_consumption",
source.stat_energy_from,
idx,
energy,
energyCompare,
"kWh",
cost,
costCompare,
showCosts,
compare
);
})();
if (!hasData && !cost && !costCompare) {
return nothing;
}
totalGrid -= energy;
totalGridCompare -= energyCompare;
const exportResult = (() => {
if (!source.stat_energy_to) return nothing;
if (cost_stat !== null) {
hasGridCost = true;
totalGridCost -= cost;
totalGridCostCompare -= costCompare;
}
const cost_stat =
source.stat_compensation ||
this._data!.info.cost_sensors[source.stat_energy_to];
const { hasData, energy, energyCompare, cost, costCompare } =
_extractStatData(source.stat_energy_to, cost_stat || null);
if (showOnlyTotals) {
return nothing;
}
if (!hasData && !cost && !costCompare) {
return nothing;
}
totalGrid -= energy;
totalGridCompare -= energyCompare;
return this._renderRow(
computedStyles,
"grid_return",
flow.stat_energy_to,
idx,
-energy,
-energyCompare,
"kWh",
-cost,
-costCompare,
showCosts,
compare
);
})}`
)}
if (cost_stat) {
hasGridCost = true;
totalGridCost -= cost;
totalGridCostCompare -= costCompare;
}
if (showOnlyTotals) {
return nothing;
}
return this._renderRow(
computedStyles,
"grid_return",
source.stat_energy_to,
idx,
-energy,
-energyCompare,
"kWh",
-cost,
-costCompare,
showCosts,
compare
);
})();
return html`${importResult}${exportResult}`;
})}
${types.grid &&
(types.grid?.[0].flow_from?.length ||
types.grid?.[0].flow_to?.length)
types.grid.some((s) => !!s.stat_energy_from || !!s.stat_energy_to)
? this._renderTotalRow(
this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_sources_table.grid_total"

View File

@@ -19,6 +19,7 @@ import type {
EnergyData,
EnergySumData,
EnergyConsumptionData,
GridSourceTypeEnergyPreference,
} from "../../../../data/energy";
import {
computeConsumptionData,
@@ -248,19 +249,19 @@ export class HuiEnergyUsageGraphCard
continue;
}
// grid source
for (const flowFrom of source.flow_from) {
const gridSource = source as GridSourceTypeEnergyPreference;
if (gridSource.stat_energy_from) {
if (statIds.from_grid) {
statIds.from_grid.push(flowFrom.stat_energy_from);
statIds.from_grid.push(gridSource.stat_energy_from);
} else {
statIds.from_grid = [flowFrom.stat_energy_from];
statIds.from_grid = [gridSource.stat_energy_from];
}
}
for (const flowTo of source.flow_to) {
if (gridSource.stat_energy_to) {
if (statIds.to_grid) {
statIds.to_grid.push(flowTo.stat_energy_to);
statIds.to_grid.push(gridSource.stat_energy_to);
} else {
statIds.to_grid = [flowTo.stat_energy_to];
statIds.to_grid = [gridSource.stat_energy_to];
}
}
}

View File

@@ -599,17 +599,15 @@ class HuiPowerSankeyCard
// Collect grid power (positive = import, negative = export)
prefs.energy_sources
.filter((source) => source.type === "grid" && source.power)
.filter((source) => source.type === "grid")
.forEach((source) => {
if (source.type === "grid" && source.power) {
source.power.forEach((powerSource) => {
const value = this._getCurrentPower(powerSource.stat_rate);
if (value > 0) {
from_grid += value;
} else if (value < 0) {
to_grid += Math.abs(value);
}
});
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);
}
}
});

View File

@@ -189,8 +189,10 @@ export class HuiPowerSourcesGraphCard
continue;
}
if (source.type === "grid" && source.power) {
statIds.grid.stats.push(...source.power.map((p) => p.stat_rate));
if (source.type === "grid") {
if (source.stat_rate) {
statIds.grid.stats.push(source.stat_rate);
}
}
}
const commonSeriesOptions: LineSeriesOption = {

View File

@@ -644,7 +644,7 @@ export const generateDefaultViewConfig = (
(source) => source.type === "grid"
) as GridSourceTypeEnergyPreference | undefined;
if (grid && grid.flow_from.length > 0) {
if (grid && grid.stat_energy_from) {
energyCard = {
title: localize(
"ui.panel.lovelace.cards.energy.energy_distribution.title_today"

View File

@@ -152,15 +152,17 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
const noOtherAreas = home.areas.length === 0;
const noFloor = home.floors.length === 0;
// Other areas / Areas / Others / nothing
const heading =
noFloor && noOtherAreas
? undefined
: noFloor
? hass.localize("ui.panel.lovelace.strategy.home.areas")
: noOtherAreas
? hass.localize("ui.panel.lovelace.strategy.home.devices")
: hass.localize("ui.panel.lovelace.strategy.home.other_areas");
// Determine heading based on floor/area configuration
let heading: string | undefined;
if (noFloor && noOtherAreas) {
heading = undefined;
} else if (noFloor) {
heading = hass.localize("ui.panel.lovelace.strategy.home.areas");
} else if (noOtherAreas) {
heading = hass.localize("ui.panel.lovelace.strategy.home.devices");
} else {
heading = hass.localize("ui.panel.lovelace.strategy.home.other_areas");
}
floorsSections.push({
type: "grid",
@@ -242,7 +244,7 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
const hasEnergy =
energyPrefs?.energy_sources.some(
(source) => source.type === "grid" && source.flow_from.length > 0
(source) => source.type === "grid" && !!source.stat_energy_from
) ?? false;
// Build summary cards (used in both mobile section and sidebar)

View File

@@ -3793,41 +3793,53 @@
"title": "Electricity grid",
"sub": "Configure the amount of energy that you consume from the grid and, if you produce energy, give back to the grid. This allows Home Assistant to track your whole home energy usage.",
"learn_more": "More information on how to get started.",
"grid_consumption": "Grid consumption",
"edit_consumption": "Edit consumption",
"delete_consumption": "Remove consumption",
"add_consumption": "Add consumption",
"return_to_grid": "Return to grid",
"edit_return": "Edit return",
"delete_return": "Remove return",
"add_return": "Add return",
"grid_connections": "Grid connections",
"add_connection": "Add grid connection",
"edit_connection": "Edit grid connection",
"delete_connection": "Delete grid connection",
"grid_carbon_footprint": "Grid carbon footprint",
"remove_co2_signal": "Remove Electricity Maps integration",
"add_co2_signal": "Add Electricity Maps integration",
"grid_power": "Grid power",
"add_power": "Add power sensor",
"edit_power": "Edit power sensor",
"delete_power": "Delete power sensor",
"power_dialog": {
"header": "Configure grid power",
"sensor_type": "Type of power measurement",
"type_standard": "Standard",
"type_inverted": "Inverted",
"dialog": {
"header": "Configure grid connection",
"energy": "Energy",
"energy_from_grid": "Energy imported from grid",
"energy_from_helper": "Pick a sensor which measures grid import in either of {unit}.",
"energy_to_grid": "Energy exported to grid",
"energy_to_helper": "Pick a sensor which measures grid export in either of {unit}.",
"import_cost": "Cost tracking",
"import_cost_para": "Select how Home Assistant should keep track of the costs of the imported energy.",
"no_cost_tracking": "Do not track costs",
"cost_stat": "Use an entity tracking the total costs",
"cost_stat_label": "Entity with the total costs",
"cost_entity": "Use an entity with current price",
"cost_entity_label": "Entity with the current price",
"cost_number": "Use a static price",
"cost_number_label": "Price",
"export_compensation": "Export compensation",
"export_compensation_para": "Do you get money back when you export energy to the grid?",
"no_compensation_tracking": "Do not track compensation",
"compensation_stat": "Use an entity tracking the total compensation",
"compensation_stat_label": "Entity with the total compensation",
"compensation_entity": "Use an entity with current rate",
"compensation_entity_label": "Entity with the current rate",
"compensation_number": "Use a static rate",
"compensation_number_label": "Rate",
"power": "Power measurement",
"sensor_type": "[%key:ui::panel::config::energy::battery::dialog::sensor_type%]",
"sensor_type_para": "[%key:ui::panel::config::energy::battery::dialog::sensor_type_para%]",
"type_none": "[%key:ui::panel::config::energy::battery::dialog::type_none%]",
"type_standard": "[%key:ui::panel::config::energy::battery::dialog::type_standard%]",
"type_inverted": "[%key:ui::panel::config::energy::battery::dialog::type_inverted%]",
"type_inverted_description": "Positive values indicate exporting to the grid, negative values indicate importing from the grid.",
"type_two_sensors": "Two sensors",
"type_two_sensors": "[%key:ui::panel::config::energy::battery::dialog::type_two_sensors%]",
"power_stat": "Power sensor",
"power_helper": "Pick a sensor which measures grid power in either of {unit}. Positive values indicate importing electricity from the grid, negative values indicate exporting electricity to the grid.",
"power_from_grid": "Power from grid",
"power_to_grid": "Power to grid"
"power_helper": "Pick a sensor which measures grid power in either of {unit}.",
"power_from": "Power imported from grid",
"power_to": "Power exported to grid"
},
"flow_dialog": {
"cost_entity_helper": "Any sensor with a unit of `{currency}/(valid energy unit)` (e.g. `{currency}/Wh` or `{currency}/kWh`) may be used and will be automatically converted.",
"from": {
"header": "Configure grid consumption",
"paragraph": "Grid consumption is the energy that flows from the energy grid to your home.",
"entity_para": "Pick a sensor which measures grid consumption in either of {unit}.",
"energy_stat": "Consumed energy",
"cost_para": "Select how Home Assistant should keep track of the costs of the consumed energy.",
"no_cost": "Do not track costs",
"cost_stat": "Use an entity tracking the total costs",
"cost_stat_input": "Entity with the total costs",
@@ -3835,20 +3847,6 @@
"cost_entity_input": "Entity with the current price",
"cost_number": "Use a static price",
"cost_number_input": "Price"
},
"to": {
"header": "Configure grid production",
"paragraph": "Grid production is the energy that flows from your solar panels to the grid.",
"entity_para": "Pick a sensor which measures grid production in either of {unit}.",
"energy_stat": "Energy returned to the grid",
"cost_para": "Do you get money back when you return energy to the grid?",
"no_cost": "I do not get money back",
"cost_stat": "Use an entity tracking the total received money",
"cost_stat_input": "Entity with the total compensation",
"cost_entity": "Use an entity with current rate",
"cost_entity_input": "Entity with the current rate",
"cost_number": "Use a static rate",
"cost_number_input": "Rate"
}
}
},
@@ -3892,13 +3890,14 @@
"power": "Battery power",
"power_helper": "Pick a sensor which measures the electricity flowing into and out of the battery in either of {unit}. Positive values indicate discharging the battery, negative values indicate charging the battery.",
"sensor_type": "Type of power measurement",
"sensor_type_para": "Power sensors show the rate of energy flow (in watts), while energy sensors show total consumption (in kWh). Adding a power sensor enables real-time monitoring.",
"type_none": "No power sensor",
"type_standard": "Standard",
"type_inverted": "Inverted",
"type_inverted_description": "Positive values indicate charging, negative values indicate discharging.",
"type_two_sensors": "Two sensors",
"power_discharge": "Discharge power",
"power_charge": "Charge power"
"power_from": "Discharge power",
"power_to": "Charge power"
}
},
"gas": {