Compare commits

...

2 Commits

Author SHA1 Message Date
Petar Petrov
45823fe4a8 css tweak 2026-04-30 14:54:11 +03:00
Petar Petrov
76568379e7 Show battery state of charge on the energy distribution card 2026-04-30 14:49:04 +03:00
6 changed files with 144 additions and 9 deletions

View File

@@ -1,3 +1,17 @@
import {
mdiBattery,
mdiBattery10,
mdiBattery20,
mdiBattery30,
mdiBattery40,
mdiBattery50,
mdiBattery60,
mdiBattery70,
mdiBattery80,
mdiBattery90,
mdiBatteryAlertVariantOutline,
mdiBatteryUnknown,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
const BATTERY_ICONS = {
@@ -12,6 +26,18 @@ const BATTERY_ICONS = {
90: "mdi:battery-90",
100: "mdi:battery",
};
const BATTERY_ICON_PATHS = {
10: mdiBattery10,
20: mdiBattery20,
30: mdiBattery30,
40: mdiBattery40,
50: mdiBattery50,
60: mdiBattery60,
70: mdiBattery70,
80: mdiBattery80,
90: mdiBattery90,
100: mdiBattery,
};
const BATTERY_CHARGING_ICONS = {
10: "mdi:battery-charging-10",
20: "mdi:battery-charging-20",
@@ -57,3 +83,15 @@ export const batteryLevelIcon = (
}
return BATTERY_ICONS[batteryRound];
};
export const batteryLevelIconPath = (batteryLevel: number | string): string => {
const batteryValue = Number(batteryLevel);
if (isNaN(batteryValue)) {
return mdiBatteryUnknown;
}
if (batteryValue <= 5) {
return mdiBatteryAlertVariantOutline;
}
const batteryRound = Math.round(batteryValue / 10) * 10;
return BATTERY_ICON_PATHS[batteryRound];
};

View File

@@ -164,6 +164,7 @@ export interface BatterySourceTypeEnergyPreference {
stat_energy_to: string;
stat_rate?: string; // always available if power_config is set
power_config?: PowerConfig;
stat_soc?: string;
}
export interface GasSourceTypeEnergyPreference {
type: "gas";

View File

@@ -29,6 +29,8 @@ import {
import type { EnergySettingsBatteryDialogParams } from "./show-dialogs-energy";
const energyUnitClasses = ["energy"];
const socStatisticsUnits = ["%"];
const socDeviceClass = "battery";
@customElement("dialog-energy-battery-settings")
export class DialogEnergyBatterySettings
@@ -180,6 +182,21 @@ export class DialogEnergyBatterySettings
@power-config-changed=${this._handlePowerConfigChanged}
></ha-energy-power-config>
<ha-statistic-picker
.hass=${this.hass}
.helpMissingEntityUrl=${energyStatisticHelpUrl}
.value=${this._source.stat_soc}
.includeStatisticsUnitOfMeasurement=${socStatisticsUnits}
.includeDeviceClass=${socDeviceClass}
.label=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.state_of_charge"
)}
.helper=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.state_of_charge_helper"
)}
@value-changed=${this._statisticSocChanged}
></ha-statistic-picker>
<ha-dialog-footer slot="footer">
<ha-button
appearance="plain"
@@ -232,6 +249,13 @@ export class DialogEnergyBatterySettings
this._powerConfig = ev.detail.powerConfig;
}
private _statisticSocChanged(ev: ValueChangedEvent<string>) {
this._source = {
...this._source!,
stat_soc: ev.detail.value || undefined,
};
}
private async _save() {
try {
const source: BatterySourceTypeEnergyPreference = {
@@ -245,6 +269,10 @@ export class DialogEnergyBatterySettings
source.power_config = { ...this._powerConfig };
}
if (this._source!.stat_soc) {
source.stat_soc = this._source!.stat_soc;
}
await this._params!.saveCallback(source);
this.closeDialog();
} catch (err: any) {
@@ -257,7 +285,8 @@ export class DialogEnergyBatterySettings
haStyle,
haStyleDialog,
css`
ha-statistic-picker {
ha-statistic-picker,
ha-energy-power-config {
display: block;
margin-bottom: var(--ha-space-4);
}

View File

@@ -16,6 +16,7 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { batteryLevelIconPath } from "../../../../common/entity/battery_icon";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
@@ -100,14 +101,34 @@ class HuiEnergyDistrubutionCard
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
return (
if (
hasConfigChanged(this, changedProps) ||
changedProps.size > 1 ||
!changedProps.has("hass") ||
(!!this._data?.co2SignalEntity &&
this.hass.states[this._data.co2SignalEntity] !==
changedProps.get("hass").states[this._data.co2SignalEntity])
);
!changedProps.has("hass")
) {
return true;
}
const oldStates = changedProps.get("hass").states;
if (
this._data?.co2SignalEntity &&
this.hass.states[this._data.co2SignalEntity] !==
oldStates[this._data.co2SignalEntity]
) {
return true;
}
const batteries = this._data
? energySourcesByType(this._data.prefs).battery
: undefined;
if (
batteries?.some(
(source) =>
source.stat_soc &&
this.hass.states[source.stat_soc] !== oldStates[source.stat_soc]
)
) {
return true;
}
return false;
}
protected willUpdate() {
@@ -174,10 +195,24 @@ class HuiEnergyDistrubutionCard
let totalBatteryIn: number | null = null;
let totalBatteryOut: number | null = null;
let batteryIconPath = mdiBatteryHigh;
if (hasBattery) {
totalBatteryIn = summedData.total.to_battery ?? 0;
totalBatteryOut = summedData.total.from_battery ?? 0;
const socValues = types
.battery!.map((source) =>
source.stat_soc
? Number(this.hass.states[source.stat_soc]?.state)
: NaN
)
.filter((value) => Number.isFinite(value));
if (socValues.length) {
const averageSoc =
socValues.reduce((sum, value) => sum + value, 0) / socValues.length;
batteryIconPath = batteryLevelIconPath(averageSoc);
}
}
let returnedToGrid: number | null = null;
@@ -569,7 +604,7 @@ class HuiEnergyDistrubutionCard
${hasBattery
? html` <div class="circle-container battery">
<div class="circle">
<ha-svg-icon .path=${mdiBatteryHigh}></ha-svg-icon>
<ha-svg-icon .path=${batteryIconPath}></ha-svg-icon>
<span class="battery-in">
<ha-svg-icon
class="small"

View File

@@ -4161,6 +4161,8 @@
"energy_helper_out": "Pick a sensor that measures the electricity flowing out of the battery in either of {unit}.",
"energy_into_battery": "Energy charged into the battery",
"energy_out_of_battery": "Energy discharged from the battery",
"state_of_charge": "Battery state of charge sensor",
"state_of_charge_helper": "Sensor reporting battery state of charge as %.",
"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",

View File

@@ -1,8 +1,17 @@
import {
mdiBattery,
mdiBattery10,
mdiBattery50,
mdiBattery90,
mdiBatteryAlertVariantOutline,
mdiBatteryUnknown,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, it, expect } from "vitest";
import { describe, expect, it } from "vitest";
import {
batteryIcon,
batteryLevelIcon,
batteryLevelIconPath,
} from "../../../src/common/entity/battery_icon";
describe("batteryIcon", () => {
@@ -43,3 +52,24 @@ describe("batteryLevelIcon", () => {
expect(batteryLevelIcon("on")).toBe("mdi:battery-alert");
});
});
describe("batteryLevelIconPath", () => {
it("rounds to the nearest 10% bucket", () => {
expect(batteryLevelIconPath(46)).toBe(mdiBattery50);
expect(batteryLevelIconPath(94)).toBe(mdiBattery90);
expect(batteryLevelIconPath(95)).toBe(mdiBattery);
});
it("returns the alert path for very low levels", () => {
expect(batteryLevelIconPath(0)).toBe(mdiBatteryAlertVariantOutline);
expect(batteryLevelIconPath(5)).toBe(mdiBatteryAlertVariantOutline);
});
it("returns the 10% bucket just above the alert threshold", () => {
expect(batteryLevelIconPath(6)).toBe(mdiBattery10);
});
it("returns the unknown path for non-numeric input", () => {
expect(batteryLevelIconPath("unavailable")).toBe(mdiBatteryUnknown);
});
});