From 19e4c0657aacfd17987d436bed31279e5517f169 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 12 Aug 2021 17:40:21 +0200 Subject: [PATCH] Add battery to energy dashboard (#9757) --- demo/src/stubs/energy.ts | 5 + demo/src/stubs/entities.ts | 18 + src/data/energy.ts | 23 +- .../components/ha-energy-battery-settings.ts | 177 ++++++++ .../dialogs/dialog-energy-battery-settings.ts | 132 ++++++ .../dialogs/dialog-energy-solar-settings.ts | 1 - .../energy/dialogs/show-dialogs-energy.ts | 17 + src/panels/config/energy/ha-config-energy.ts | 6 + .../energy/cards/energy-setup-wizard-card.ts | 15 +- .../energy/hui-energy-distribution-card.ts | 413 ++++++++++++++---- .../energy/hui-energy-sources-table-card.ts | 100 +++++ .../energy/hui-energy-usage-graph-card.ts | 138 +++++- src/resources/ha-style.ts | 2 + src/translations/en.json | 5 + 14 files changed, 954 insertions(+), 98 deletions(-) create mode 100644 src/panels/config/energy/components/ha-energy-battery-settings.ts create mode 100644 src/panels/config/energy/dialogs/dialog-energy-battery-settings.ts diff --git a/demo/src/stubs/energy.ts b/demo/src/stubs/energy.ts index 58b7768b61..80af3d1ea1 100644 --- a/demo/src/stubs/energy.ts +++ b/demo/src/stubs/energy.ts @@ -44,6 +44,11 @@ export const mockEnergy = (hass: MockHomeAssistant) => { stat_energy_from: "sensor.solar_production", config_entry_solar_forecast: ["solar_forecast"], }, + { + type: "battery", + stat_energy_from: "sensor.battery_output", + stat_energy_to: "sensor.battery_input", + }, ], device_consumption: [ { diff --git a/demo/src/stubs/entities.ts b/demo/src/stubs/entities.ts index 6ebfae96dc..df7075e161 100644 --- a/demo/src/stubs/entities.ts +++ b/demo/src/stubs/entities.ts @@ -18,6 +18,24 @@ export const energyEntities = () => unit_of_measurement: "kWh", }, }, + "sensor.battery_input": { + entity_id: "sensor.battery_input", + state: "4", + attributes: { + last_reset: "1970-01-01T00:00:00:00+00", + friendly_name: "Battery Input", + unit_of_measurement: "kWh", + }, + }, + "sensor.battery_output": { + entity_id: "sensor.battery_output", + state: "3", + attributes: { + last_reset: "1970-01-01T00:00:00:00+00", + friendly_name: "Battery Output", + unit_of_measurement: "kWh", + }, + }, "sensor.energy_consumption_tarif_1": { entity_id: "sensor.energy_consumption_tarif_1 ", state: "88.6", diff --git a/src/data/energy.ts b/src/data/energy.ts index a35dded6d1..055e61cadb 100644 --- a/src/data/energy.ts +++ b/src/data/energy.ts @@ -47,6 +47,13 @@ export const emptySolarEnergyPreference = config_entry_solar_forecast: null, }); +export const emptyBatteryEnergyPreference = + (): BatterySourceTypeEnergyPreference => ({ + type: "battery", + stat_energy_from: "", + stat_energy_to: "", + }); + export interface DeviceConsumptionEnergyPreference { // This is an ever increasing value stat_consumption: string; @@ -94,9 +101,16 @@ export interface SolarSourceTypeEnergyPreference { config_entry_solar_forecast: string[] | null; } +export interface BatterySourceTypeEnergyPreference { + type: "battery"; + stat_energy_from: string; + stat_energy_to: string; +} + type EnergySource = | SolarSourceTypeEnergyPreference - | GridSourceTypeEnergyPreference; + | GridSourceTypeEnergyPreference + | BatterySourceTypeEnergyPreference; export interface EnergyPreferences { energy_sources: EnergySource[]; @@ -132,6 +146,7 @@ export const saveEnergyPreferences = async ( interface EnergySourceByType { grid?: GridSourceTypeEnergyPreference[]; solar?: SolarSourceTypeEnergyPreference[]; + battery?: BatterySourceTypeEnergyPreference[]; } export const energySourcesByType = (prefs: EnergyPreferences) => { @@ -203,6 +218,12 @@ const getEnergyData = async ( continue; } + if (source.type === "battery") { + statIDs.push(source.stat_energy_from); + statIDs.push(source.stat_energy_to); + continue; + } + // grid source for (const flowFrom of source.flow_from) { statIDs.push(flowFrom.stat_energy_from); diff --git a/src/panels/config/energy/components/ha-energy-battery-settings.ts b/src/panels/config/energy/components/ha-energy-battery-settings.ts new file mode 100644 index 0000000000..9caa3b8e60 --- /dev/null +++ b/src/panels/config/energy/components/ha-energy-battery-settings.ts @@ -0,0 +1,177 @@ +import "@material/mwc-button/mwc-button"; +import { mdiBatteryHigh, mdiDelete, mdiPencil } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { computeStateName } from "../../../../common/entity/compute_state_name"; +import "../../../../components/entity/ha-statistic-picker"; +import "../../../../components/ha-card"; +import "../../../../components/ha-settings-row"; +import { + BatterySourceTypeEnergyPreference, + EnergyPreferences, + energySourcesByType, + saveEnergyPreferences, +} from "../../../../data/energy"; +import { + showConfirmationDialog, + showAlertDialog, +} from "../../../../dialogs/generic/show-dialog-box"; +import { haStyle } from "../../../../resources/styles"; +import { HomeAssistant } from "../../../../types"; +import { documentationUrl } from "../../../../util/documentation-url"; +import { showEnergySettingsBatteryDialog } from "../dialogs/show-dialogs-energy"; +import { energyCardStyles } from "./styles"; + +@customElement("ha-energy-battery-settings") +export class EnergyBatterySettings extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) + public preferences!: EnergyPreferences; + + protected render(): TemplateResult { + const types = energySourcesByType(this.preferences); + + const batterySources = types.battery || []; + + return html` + +

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

+ +
+

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

+

Battery systems

+ ${batterySources.map((source) => { + const fromEntityState = this.hass.states[source.stat_energy_from]; + const toEntityState = this.hass.states[source.stat_energy_to]; + return html` +
+ ${toEntityState?.attributes.icon + ? html`` + : html``} +
+ ${toEntityState + ? computeStateName(toEntityState) + : source.stat_energy_from} + ${fromEntityState + ? computeStateName(fromEntityState) + : source.stat_energy_to} +
+ + + + + + +
+ `; + })} +
+ + Add battery system +
+
+
+ `; + } + + private _addSource() { + showEnergySettingsBatteryDialog(this, { + saveCallback: async (source) => { + await this._savePreferences({ + ...this.preferences, + energy_sources: this.preferences.energy_sources.concat(source), + }); + }, + }); + } + + private _editSource(ev) { + const origSource: BatterySourceTypeEnergyPreference = + ev.currentTarget.closest(".row").source; + showEnergySettingsBatteryDialog(this, { + source: { ...origSource }, + saveCallback: async (newSource) => { + await this._savePreferences({ + ...this.preferences, + energy_sources: this.preferences.energy_sources.map((src) => + src === origSource ? newSource : src + ), + }); + }, + }); + } + + private async _deleteSource(ev) { + const sourceToDelete: BatterySourceTypeEnergyPreference = + ev.currentTarget.closest(".row").source; + + if ( + !(await showConfirmationDialog(this, { + title: "Are you sure you want to delete this source?", + })) + ) { + return; + } + + try { + await this._savePreferences({ + ...this.preferences, + energy_sources: this.preferences.energy_sources.filter( + (source) => source !== sourceToDelete + ), + }); + } catch (err) { + showAlertDialog(this, { title: `Failed to save config: ${err.message}` }); + } + } + + private async _savePreferences(preferences: EnergyPreferences) { + const result = await saveEnergyPreferences(this.hass, preferences); + fireEvent(this, "value-changed", { value: result }); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + energyCardStyles, + css` + .row { + height: 58px; + } + .content { + display: flex; + flex-direction: column; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-energy-battery-settings": EnergyBatterySettings; + } +} diff --git a/src/panels/config/energy/dialogs/dialog-energy-battery-settings.ts b/src/panels/config/energy/dialogs/dialog-energy-battery-settings.ts new file mode 100644 index 0000000000..20eda2eead --- /dev/null +++ b/src/panels/config/energy/dialogs/dialog-energy-battery-settings.ts @@ -0,0 +1,132 @@ +import { mdiBatteryHigh } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-dialog"; +import { + BatterySourceTypeEnergyPreference, + emptyBatteryEnergyPreference, +} from "../../../../data/energy"; +import { HassDialog } from "../../../../dialogs/make-dialog-manager"; +import { haStyle, haStyleDialog } from "../../../../resources/styles"; +import { HomeAssistant } from "../../../../types"; +import { EnergySettingsBatteryDialogParams } from "./show-dialogs-energy"; +import "@material/mwc-button/mwc-button"; +import "../../../../components/entity/ha-statistic-picker"; + +const energyUnits = ["kWh"]; + +@customElement("dialog-energy-battery-settings") +export class DialogEnergyBatterySettings + extends LitElement + implements HassDialog +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: EnergySettingsBatteryDialogParams; + + @state() private _source?: BatterySourceTypeEnergyPreference; + + @state() private _error?: string; + + public async showDialog( + params: EnergySettingsBatteryDialogParams + ): Promise { + this._params = params; + this._source = params.source + ? { ...params.source } + : (this._source = emptyBatteryEnergyPreference()); + } + + public closeDialog(): void { + this._params = undefined; + this._source = undefined; + this._error = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render(): TemplateResult { + if (!this._params || !this._source) { + return html``; + } + + return html` + + Configure battery system`} + @closed=${this.closeDialog} + > + ${this._error ? html`

${this._error}

` : ""} + + + + + + + ${this.hass.localize("ui.common.cancel")} + + + ${this.hass.localize("ui.common.save")} + +
+ `; + } + + private _statisticToChanged(ev: CustomEvent<{ value: string }>) { + this._source = { ...this._source!, stat_energy_to: ev.detail.value }; + } + + private _statisticFromChanged(ev: CustomEvent<{ value: string }>) { + this._source = { ...this._source!, stat_energy_from: ev.detail.value }; + } + + private async _save() { + try { + await this._params!.saveCallback(this._source!); + this.closeDialog(); + } catch (e) { + this._error = e.message; + } + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + ha-dialog { + --mdc-dialog-max-width: 430px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-energy-battery-settings": DialogEnergyBatterySettings; + } +} diff --git a/src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts b/src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts index 9d6501f1bf..3cb983a857 100644 --- a/src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts +++ b/src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts @@ -17,7 +17,6 @@ import "../../../../components/ha-radio"; import "../../../../components/ha-checkbox"; import type { HaCheckbox } from "../../../../components/ha-checkbox"; import "../../../../components/ha-formfield"; -import "../../../../components/entity/ha-entity-picker"; import type { HaRadio } from "../../../../components/ha-radio"; import { showConfigFlowDialog } from "../../../../dialogs/config-flow/show-dialog-config-flow"; import { ConfigEntry, getConfigEntries } from "../../../../data/config_entries"; diff --git a/src/panels/config/energy/dialogs/show-dialogs-energy.ts b/src/panels/config/energy/dialogs/show-dialogs-energy.ts index f471cce7a7..718a09791a 100644 --- a/src/panels/config/energy/dialogs/show-dialogs-energy.ts +++ b/src/panels/config/energy/dialogs/show-dialogs-energy.ts @@ -1,5 +1,6 @@ import { fireEvent } from "../../../../common/dom/fire_event"; import { + BatterySourceTypeEnergyPreference, DeviceConsumptionEnergyPreference, FlowFromGridSourceEnergyPreference, FlowToGridSourceEnergyPreference, @@ -33,6 +34,11 @@ export interface EnergySettingsSolarDialogParams { saveCallback: (source: SolarSourceTypeEnergyPreference) => Promise; } +export interface EnergySettingsBatteryDialogParams { + source?: BatterySourceTypeEnergyPreference; + saveCallback: (source: BatterySourceTypeEnergyPreference) => Promise; +} + export interface EnergySettingsDeviceDialogParams { saveCallback: (device: DeviceConsumptionEnergyPreference) => Promise; } @@ -48,6 +54,17 @@ export const showEnergySettingsDeviceDialog = ( }); }; +export const showEnergySettingsBatteryDialog = ( + element: HTMLElement, + dialogParams: EnergySettingsBatteryDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-energy-battery-settings", + dialogImport: () => import("./dialog-energy-battery-settings"), + dialogParams: dialogParams, + }); +}; + export const showEnergySettingsSolarDialog = ( element: HTMLElement, dialogParams: EnergySettingsSolarDialogParams diff --git a/src/panels/config/energy/ha-config-energy.ts b/src/panels/config/energy/ha-config-energy.ts index fab928a23d..1520ab3848 100644 --- a/src/panels/config/energy/ha-config-energy.ts +++ b/src/panels/config/energy/ha-config-energy.ts @@ -10,6 +10,7 @@ import { configSections } from "../ha-panel-config"; import "./components/ha-energy-device-settings"; import "./components/ha-energy-grid-settings"; import "./components/ha-energy-solar-settings"; +import "./components/ha-energy-battery-settings"; const INITIAL_CONFIG: EnergyPreferences = { energy_sources: [], @@ -81,6 +82,11 @@ class HaConfigEnergy extends LitElement { .preferences=${this._preferences!} @value-changed=${this._prefsChanged} > + Step ${this._step + 1} of 3

${this._step === 0 - ? html` ` : this._step === 1 - ? html` ` - : html` ` + : html`${this.hass.localize("ui.panel.energy.setup.back")}` : html`
`} - ${this._step < 2 + ${this._step < 3 ? html`${this.hass.localize("ui.panel.energy.setup.next")}` diff --git a/src/panels/lovelace/cards/energy/hui-energy-distribution-card.ts b/src/panels/lovelace/cards/energy/hui-energy-distribution-card.ts index 78b13d914c..fedfe067cf 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-distribution-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-distribution-card.ts @@ -1,6 +1,9 @@ import { + mdiArrowDown, mdiArrowLeft, mdiArrowRight, + mdiArrowUp, + mdiBatteryHigh, mdiHome, mdiLeaf, mdiSolarPower, @@ -75,9 +78,10 @@ class HuiEnergyDistrubutionCard const hasConsumption = true; const hasSolarProduction = types.solar !== undefined; + const hasBattery = types.battery !== undefined; const hasReturnToGrid = hasConsumption && types.grid![0].flow_to.length > 0; - const totalGridConsumption = + const totalFromGrid = calculateStatisticsSumGrowth( this._data.stats, types.grid![0].flow_from.map((flow) => flow.stat_energy_from) @@ -93,30 +97,97 @@ class HuiEnergyDistrubutionCard ) || 0; } - let productionReturnedToGrid: number | null = null; + let totalBatteryIn: number | null = null; + let totalBatteryOut: number | null = null; + + if (hasBattery) { + totalBatteryIn = + calculateStatisticsSumGrowth( + this._data.stats, + types.battery!.map((source) => source.stat_energy_to) + ) || 0; + totalBatteryOut = + calculateStatisticsSumGrowth( + this._data.stats, + types.battery!.map((source) => source.stat_energy_from) + ) || 0; + } + + let returnedToGrid: number | null = null; if (hasReturnToGrid) { - productionReturnedToGrid = + returnedToGrid = calculateStatisticsSumGrowth( this._data.stats, types.grid![0].flow_to.map((flow) => flow.stat_energy_to) ) || 0; } - const solarConsumption = Math.max( - 0, - (totalSolarProduction || 0) - (productionReturnedToGrid || 0) - ); + let solarConsumption: number | null = null; + if (hasSolarProduction) { + solarConsumption = + (totalSolarProduction || 0) - + (returnedToGrid || 0) - + (totalBatteryIn || 0); + } - const totalHomeConsumption = totalGridConsumption + solarConsumption; + let batteryFromGrid: null | number = null; + let batteryToGrid: null | number = null; + if (solarConsumption !== null && solarConsumption < 0) { + // What we returned to the grid and what went in to the battery is more than produced, + // so we have used grid energy to fill the battery + // or returned battery energy to the grid + if (hasBattery) { + batteryFromGrid = solarConsumption * -1; + if (batteryFromGrid > totalFromGrid) { + batteryToGrid = Math.min(0, batteryFromGrid - totalFromGrid); + batteryFromGrid = totalFromGrid; + } + } + solarConsumption = 0; + } + + let solarToBattery: null | number = null; + if (hasSolarProduction && hasBattery) { + if (!batteryToGrid) { + batteryToGrid = Math.max( + 0, + (returnedToGrid || 0) - + (totalSolarProduction || 0) - + (totalBatteryIn || 0) - + (batteryFromGrid || 0) + ); + } + solarToBattery = totalBatteryIn! - (batteryFromGrid || 0); + } else if (!hasSolarProduction && hasBattery) { + batteryToGrid = returnedToGrid; + } + + let batteryConsumption: number | null = null; + if (hasBattery) { + batteryConsumption = (totalBatteryOut || 0) - (batteryToGrid || 0); + } + + const gridConsumption = Math.max(0, totalFromGrid - (batteryFromGrid || 0)); + + const totalHomeConsumption = Math.max( + 0, + gridConsumption + (solarConsumption || 0) + (batteryConsumption || 0) + ); let homeSolarCircumference: number | undefined; if (hasSolarProduction) { homeSolarCircumference = - CIRCLE_CIRCUMFERENCE * (solarConsumption / totalHomeConsumption); + CIRCLE_CIRCUMFERENCE * (solarConsumption! / totalHomeConsumption); } - let lowCarbonConsumption: number | undefined; + let homeBatteryCircumference: number | undefined; + if (batteryConsumption) { + homeBatteryCircumference = + CIRCLE_CIRCUMFERENCE * (batteryConsumption / totalHomeConsumption); + } + + let lowCarbonEnergy: number | undefined; let homeLowCarbonCircumference: number | undefined; let homeHighCarbonCircumference: number | undefined; @@ -129,7 +200,7 @@ class HuiEnergyDistrubutionCard this._data.co2SignalEntity in this._data.stats ) { // Calculate high carbon consumption - const highCarbonConsumption = calculateStatisticsSumGrowthWithPercentage( + const highCarbonEnergy = calculateStatisticsSumGrowthWithPercentage( this._data.stats[this._data.co2SignalEntity], types .grid![0].flow_from.map( @@ -144,8 +215,17 @@ class HuiEnergyDistrubutionCard electricityMapUrl += `/zone/${co2State.attributes.country_code}`; } - if (highCarbonConsumption !== null) { - lowCarbonConsumption = totalGridConsumption - highCarbonConsumption; + if (highCarbonEnergy !== null) { + lowCarbonEnergy = totalFromGrid - highCarbonEnergy; + + let highCarbonConsumption: number; + if (gridConsumption !== totalFromGrid) { + // Only get the part that was used for consumption and not the battery + highCarbonConsumption = + highCarbonEnergy * (gridConsumption / totalFromGrid); + } else { + highCarbonConsumption = highCarbonEnergy; + } homeHighCarbonCircumference = CIRCLE_CIRCUMFERENCE * (highCarbonConsumption / totalHomeConsumption); @@ -153,16 +233,26 @@ class HuiEnergyDistrubutionCard homeLowCarbonCircumference = CIRCLE_CIRCUMFERENCE - (homeSolarCircumference || 0) - + (homeBatteryCircumference || 0) - homeHighCarbonCircumference; } } + const totalLines = + gridConsumption + + (solarConsumption || 0) + + (returnedToGrid ? returnedToGrid - (batteryToGrid || 0) : 0) + + (solarToBattery || 0) + + (batteryConsumption || 0) + + (batteryFromGrid || 0) + + (batteryToGrid || 0); + return html`
- ${lowCarbonConsumption !== undefined || hasSolarProduction + ${lowCarbonEnergy !== undefined || hasSolarProduction ? html`
- ${lowCarbonConsumption === undefined + ${lowCarbonEnergy === undefined ? html`
` : html`
Non-fossil @@ -173,12 +263,10 @@ class HuiEnergyDistrubutionCard rel="noopener no referrer" > - ${lowCarbonConsumption - ? formatNumber( - lowCarbonConsumption, - this.hass.locale, - { maximumFractionDigits: 1 } - ) + ${lowCarbonEnergy + ? formatNumber(lowCarbonEnergy, this.hass.locale, { + maximumFractionDigits: 1, + }) : "-"} kWh @@ -207,33 +295,29 @@ class HuiEnergyDistrubutionCard
+ ${returnedToGrid !== null + ? html` + ${formatNumber(returnedToGrid, this.hass.locale, { + maximumFractionDigits: 1, + })} + kWh + ` + : ""} ${hasReturnToGrid ? html`` - : ""}${formatNumber( - totalGridConsumption, - this.hass.locale, - { maximumFractionDigits: 1 } - )} + : ""}${formatNumber(totalFromGrid, this.hass.locale, { + maximumFractionDigits: 1, + })} kWh - ${productionReturnedToGrid !== null - ? html` - ${formatNumber( - productionReturnedToGrid, - this.hass.locale, - { maximumFractionDigits: 1 } - )} - kWh - ` - : ""}
Grid
@@ -268,6 +352,23 @@ class HuiEnergyDistrubutionCard }" />` : ""} + ${homeBatteryCircumference + ? svg`` + : ""} ${homeLowCarbonCircumference ? svg` @@ -305,7 +409,39 @@ class HuiEnergyDistrubutionCard Home
-
+ ${hasBattery + ? html`
+
+
+
+ + + ${formatNumber(totalBatteryIn || 0, this.hass.locale, { + maximumFractionDigits: 1, + })} + kWh + + + ${formatNumber(totalBatteryOut || 0, this.hass.locale, { + maximumFractionDigits: 1, + })} + kWh +
+ Battery +
+
+
` + : ""} +
` : ""} @@ -323,19 +461,45 @@ class HuiEnergyDistrubutionCard ? svg`` + : ""} + ${hasBattery + ? svg` + + ` + : ""} + ${hasBattery && hasSolarProduction + ? svg`` : ""} - ${productionReturnedToGrid && hasSolarProduction + ${returnedToGrid && hasSolarProduction ? svg` ` : ""} - ${totalSolarProduction + ${solarConsumption ? svg` ` : ""} - ${totalGridConsumption + ${gridConsumption ? svg` ` : ""} + ${solarToBattery + ? svg` + + + + ` + : ""} + ${batteryConsumption + ? svg` + + + + ` + : ""} + ${batteryFromGrid + ? svg` + + + + ` + : ""} + ${batteryToGrid + ? svg` + + + + ` + : ""}
@@ -433,6 +643,10 @@ class HuiEnergyDistrubutionCard padding: 0 16px 16px; box-sizing: border-box; } + .lines.battery { + bottom: 100px; + height: 156px; + } .lines svg { width: calc(100% - 160px); height: 100%; @@ -456,6 +670,10 @@ class HuiEnergyDistrubutionCard margin-left: 4px; height: 130px; } + .circle-container.battery { + height: 110px; + justify-content: flex-end; + } .spacer { width: 84px; } @@ -523,11 +741,48 @@ class HuiEnergyDistrubutionCard stroke-width: 4; fill: var(--energy-solar-color); } - path.return, - circle.return { + .battery .circle { + border-color: var(--energy-battery-in-color); + } + circle.battery, + path.battery { + stroke: var(--energy-battery-out-color); + } + path.battery-house, + circle.battery-house { + stroke: var(--energy-battery-out-color); + } + circle.battery-house { + stroke-width: 4; + fill: var(--energy-battery-out-color); + } + path.battery-solar, + circle.battery-solar { + stroke: var(--energy-battery-in-color); + } + circle.battery-solar { + stroke-width: 4; + fill: var(--energy-battery-in-color); + } + .battery-in { + color: var(--energy-battery-in-color); + } + .battery-out { + color: var(--energy-battery-out-color); + } + path.battery-from-grid { + stroke: var(--energy-grid-consumption-color); + } + path.battery-to-grid { stroke: var(--energy-grid-return-color); } - circle.return { + path.return, + circle.return, + circle.battery-to-grid { + stroke: var(--energy-grid-return-color); + } + circle.return, + circle.battery-to-grid { stroke-width: 4; fill: var(--energy-grid-return-color); } @@ -541,10 +796,12 @@ class HuiEnergyDistrubutionCard color: var(--energy-grid-consumption-color); } circle.grid, + circle.battery-from-grid, path.grid { stroke: var(--energy-grid-consumption-color); } - circle.grid { + circle.grid, + circle.battery-from-grid { stroke-width: 4; fill: var(--energy-grid-consumption-color); } diff --git a/src/panels/lovelace/cards/energy/hui-energy-sources-table-card.ts b/src/panels/lovelace/cards/energy/hui-energy-sources-table-card.ts index 7df70cce72..79e7699a89 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-sources-table-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-sources-table-card.ts @@ -73,6 +73,7 @@ export class HuiEnergySourcesTableCard let totalGrid = 0; let totalSolar = 0; + let totalBattery = 0; let totalCost = 0; const types = energySourcesByType(this._data.prefs); @@ -81,6 +82,12 @@ export class HuiEnergySourcesTableCard const solarColor = computedStyles .getPropertyValue("--energy-solar-color") .trim(); + const batteryFromColor = computedStyles + .getPropertyValue("--energy-battery-out-color") + .trim(); + const batteryToColor = computedStyles + .getPropertyValue("--energy-battery-in-color") + .trim(); const returnColor = computedStyles .getPropertyValue("--energy-grid-return-color") .trim(); @@ -190,6 +197,99 @@ export class HuiEnergySourcesTableCard : ""} ` : ""} + ${types.battery?.map((source, idx) => { + const entityFrom = this.hass.states[source.stat_energy_from]; + const entityTo = this.hass.states[source.stat_energy_to]; + const energyFrom = + calculateStatisticSumGrowth( + this._data!.stats[source.stat_energy_from] + ) || 0; + const energyTo = + calculateStatisticSumGrowth( + this._data!.stats[source.stat_energy_to] + ) || 0; + totalBattery += energyFrom - energyTo; + const fromColor = + idx > 0 + ? rgb2hex( + lab2rgb( + labDarken(rgb2lab(hex2rgb(batteryFromColor)), idx) + ) + ) + : batteryFromColor; + const toColor = + idx > 0 + ? rgb2hex( + lab2rgb( + labDarken(rgb2lab(hex2rgb(batteryToColor)), idx) + ) + ) + : batteryToColor; + return html` + +
+ + + ${entityFrom + ? computeStateName(entityFrom) + : source.stat_energy_from} + + + ${formatNumber(energyFrom, this.hass.locale)} kWh + + ${showCosts + ? html`` + : ""} + + + +
+ + + ${entityTo + ? computeStateName(entityTo) + : source.stat_energy_from} + + + ${formatNumber(energyTo * -1, this.hass.locale)} kWh + + ${showCosts + ? html`` + : ""} + `; + })} + ${types.battery + ? html` + + + Battery total + + + ${formatNumber(totalBattery, this.hass.locale)} kWh + + ${showCosts + ? html`` + : ""} + ` + : ""} ${types.grid?.map( (source) => html`${source.flow_from.map((flow, idx) => { const entity = this.hass.states[flow.stat_energy_from]; diff --git a/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts index 131efb96a6..94b4a388e5 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts @@ -244,6 +244,8 @@ export class HuiEnergyUsageGraphCard to_grid?: string[]; from_grid?: string[]; solar?: string[]; + to_battery?: string[]; + from_battery?: string[]; } = {}; for (const source of energyData.prefs.energy_sources) { @@ -256,6 +258,17 @@ export class HuiEnergyUsageGraphCard continue; } + if (source.type === "battery") { + if (statistics.to_battery) { + statistics.to_battery.push(source.stat_energy_to); + statistics.from_battery!.push(source.stat_energy_from); + } else { + statistics.to_battery = [source.stat_energy_to]; + statistics.from_battery = [source.stat_energy_from]; + } + continue; + } + // grid source for (const flowFrom of source.flow_from) { if (statistics.from_grid) { @@ -306,21 +319,47 @@ export class HuiEnergyUsageGraphCard } const combinedData: { - [key: string]: { [statId: string]: { [start: string]: number } }; + to_grid?: { [statId: string]: { [start: string]: number } }; + to_battery?: { [statId: string]: { [start: string]: number } }; + from_grid?: { [statId: string]: { [start: string]: number } }; + used_grid?: { [statId: string]: { [start: string]: number } }; + used_solar?: { [statId: string]: { [start: string]: number } }; + used_battery?: { [statId: string]: { [start: string]: number } }; + } = {}; + + const summedData: { + to_grid?: { [start: string]: number }; + from_grid?: { [start: string]: number }; + to_battery?: { [start: string]: number }; + from_battery?: { [start: string]: number }; + solar?: { [start: string]: number }; } = {}; - const summedData: { [key: string]: { [start: string]: number } } = {}; const computedStyles = getComputedStyle(this); const colors = { to_grid: computedStyles .getPropertyValue("--energy-grid-return-color") .trim(), + to_battery: computedStyles + .getPropertyValue("--energy-battery-in-color") + .trim(), from_grid: computedStyles .getPropertyValue("--energy-grid-consumption-color") .trim(), + used_grid: computedStyles + .getPropertyValue("--energy-grid-consumption-color") + .trim(), used_solar: computedStyles .getPropertyValue("--energy-solar-color") .trim(), + used_battery: computedStyles + .getPropertyValue("--energy-battery-out-color") + .trim(), + }; + const labels = { + used_grid: "Combined from grid", + used_solar: "Consumed solar", + used_battery: "Consumed battery", }; const backgroundColor = computedStyles @@ -328,8 +367,14 @@ export class HuiEnergyUsageGraphCard .trim(); Object.entries(statistics).forEach(([key, statIds]) => { - const sum = ["solar", "to_grid"].includes(key); - const add = key !== "solar"; + const sum = [ + "solar", + "to_grid", + "from_grid", + "to_battery", + "from_battery", + ].includes(key); + const add = !["solar", "from_battery"].includes(key); const totalStats: { [start: string]: number } = {}; const sets: { [statId: string]: { [start: string]: number } } = {}; statIds!.forEach((id) => { @@ -374,15 +419,72 @@ export class HuiEnergyUsageGraphCard } }); - if (summedData.to_grid && summedData.solar) { + const grid_to_battery = {}; + const battery_to_grid = {}; + if ((summedData.to_grid || summedData.to_battery) && summedData.solar) { const used_solar = {}; for (const start of Object.keys(summedData.solar)) { - used_solar[start] = Math.max( - (summedData.solar[start] || 0) - (summedData.to_grid[start] || 0), - 0 - ); + used_solar[start] = + (summedData.solar[start] || 0) - + (summedData.to_grid?.[start] || 0) - + (summedData.to_battery?.[start] || 0); + if (used_solar[start] < 0) { + if (summedData.to_battery) { + grid_to_battery[start] = used_solar[start] * -1; + if (grid_to_battery[start] > (summedData.from_grid?.[start] || 0)) { + battery_to_grid[start] = Math.min( + 0, + grid_to_battery[start] - (summedData.from_grid?.[start] || 0) + ); + grid_to_battery[start] = summedData.from_grid?.[start]; + } + } + used_solar[start] = 0; + } } - combinedData.used_solar = { used_solar: used_solar }; + combinedData.used_solar = { used_solar }; + } + + if (summedData.from_battery) { + if (summedData.to_grid) { + const used_battery = {}; + for (const start of Object.keys(summedData.from_battery)) { + used_battery[start] = + (summedData.from_battery![start] || 0) - + (battery_to_grid[start] || 0); + } + combinedData.used_battery = { used_battery }; + } else { + combinedData.used_battery = { used_battery: summedData.from_battery }; + } + } + + if (combinedData.from_grid && summedData.to_battery) { + const used_grid = {}; + for (const start of Object.keys(grid_to_battery)) { + let noOfSources = 0; + let source: string; + for (const [key, stats] of Object.entries(combinedData.from_grid)) { + if (stats[start]) { + source = key; + noOfSources++; + } + if (noOfSources > 1) { + break; + } + } + if (noOfSources === 1) { + combinedData.from_grid[source!][start] -= grid_to_battery[start] || 0; + } else { + let total_from_grid = 0; + Object.values(combinedData.from_grid).forEach((stats) => { + total_from_grid += stats[start] || 0; + delete stats[start]; + }); + used_grid[start] = total_from_grid - (grid_to_battery[start] || 0); + } + } + combinedData.used_grid = { used_grid }; } let allKeys: string[] = []; @@ -406,12 +508,17 @@ export class HuiEnergyUsageGraphCard data.push({ label: - type === "used_solar" - ? "Consumed solar" + type in labels + ? labels[type] : entity ? computeStateName(entity) : statId, - order: type === "used_solar" ? 0 : idx + 1, + order: + type === "used_solar" + ? 0 + : type === "to_battery" + ? Object.keys(combinedData).length + : idx + 1, borderColor, backgroundColor: hexBlend(borderColor, backgroundColor, 50), stack: "stack", @@ -425,7 +532,10 @@ export class HuiEnergyUsageGraphCard // @ts-expect-error data[0].data.push({ x: date.getTime(), - y: value && type === "to_grid" ? -1 * value : value, + y: + value && ["to_grid", "to_battery"].includes(type) + ? -1 * value + : value, }); } diff --git a/src/resources/ha-style.ts b/src/resources/ha-style.ts index d8bb070271..1cf30e1054 100644 --- a/src/resources/ha-style.ts +++ b/src/resources/ha-style.ts @@ -88,6 +88,8 @@ documentContainer.innerHTML = ` --energy-grid-return-color: #673ab7; --energy-solar-color: #ff9800; --energy-non-fossil-color: #0f9d58; + --energy-battery-out-color: #4db6ac; + --energy-battery-in-color: #f06292; /* opacity for dark text on a light background */ --dark-divider-opacity: 0.12; diff --git a/src/translations/en.json b/src/translations/en.json index 1f4ab1970e..ff9df30afb 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1046,6 +1046,11 @@ "stat_return_to_grid": "Solar energy returned to the grid", "stat_predicted_production": "Prediction of your solar energy production" }, + "battery": { + "title": "Home Battery Storage", + "sub": "If you have a battery system, you can configure it to monitor how much energy was stored and used from your battery.", + "learn_more": "More information on how to get started." + }, "device_consumption": { "title": "Individual devices", "sub": "Tracking the energy usage of individual devices allows Home Assistant to break down your energy usage by device.",