Battery Boost UI: disable button when battery on hold (#28489)

This commit is contained in:
Michael Geers 2026-03-27 13:08:09 +01:00 • committed by GitHub
parent 2499c09b94
commit 948dda081c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 132 additions and 39 deletions

View file

@ -2,7 +2,7 @@
<button
class="root position-relative"
tabindex="0"
:class="{ active, belowLimit, full }"
:class="{ active, belowLimit, batteryHold, full }"
:style="{ '--soc': `${adjustedSoc}%` }"
:disabled="disabled"
:aria-label="ariaLabel"
@ -17,17 +17,17 @@
<div class="progress-bar bg-primary progress-bar-striped progress-bar-animated"></div>
</div>
<div class="icon-wrapper" :style="iconStyle">
<BatteryBoost :active="active && !belowLimit" />
<BatteryBoost :active="active && available" />
</div>
<div class="icon-wrapper text-white" :style="iconActiveStyle">
<BatteryBoost :active="active && !belowLimit" />
<BatteryBoost :active="active && available" />
</div>
</button>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { CHARGE_MODE, type Timeout } from "@/types/evcc";
import { BATTERY_MODE, CHARGE_MODE, type Timeout } from "@/types/evcc";
import BatteryBoost from "../MaterialIcon/BatteryBoost.vue";
export default defineComponent({
@ -40,6 +40,7 @@ export default defineComponent({
batteryBoostLimit: { type: Number, default: 100 },
mode: String as PropType<CHARGE_MODE>,
batterySoc: { type: Number, default: 0 },
batteryMode: String as PropType<BATTERY_MODE>,
},
emits: ["updated", "status"],
data() {
@ -66,6 +67,12 @@ export default defineComponent({
belowLimit(): boolean {
return this.batterySoc < this.batteryBoostLimit;
},
batteryHold(): boolean {
return this.batteryMode === BATTERY_MODE.HOLD;
},
available(): boolean {
return !this.belowLimit && !this.batteryHold;
},
iconStyle() {
return {
clipPath: this.active ? `inset(0 0 calc(var(--soc)) 0)` : undefined,
@ -76,6 +83,7 @@ export default defineComponent({
},
ariaLabel(): string {
const t = (key: string) => this.$t(`main.loadpointSettings.batteryBoost.${key}`);
if (this.batteryHold) return t("stateHold");
if (this.active) return t("stateActive");
if (this.belowLimit) return t("stateBelowLimit");
return t("stateReady");
@ -105,6 +113,12 @@ export default defineComponent({
});
const newValue = !this.active;
// battery hold: only show message, don't toggle
if (newValue && this.batteryHold) {
status("batteryBoostHold");
return;
}
// below limit: only show message, don't toggle
if (newValue && this.belowLimit) {
status("batteryBoostBelowLimit");
@ -157,7 +171,8 @@ export default defineComponent({
color: inherit;
opacity: 0.25;
}
.root.belowLimit:not(:disabled) {
.root.belowLimit:not(:disabled),
.root.batteryHold:not(:disabled) {
opacity: 0.5;
}
.root:before,

View file

@ -127,6 +127,7 @@ import type {
Vehicle,
Forecast,
SMART_COST_TYPE,
BATTERY_MODE,
} from "@/types/evcc";
import type { PlanStrategy } from "@/components/ChargingPlans/types";
@ -160,6 +161,7 @@ export default defineComponent({
batteryBoostLimit: { type: Number, default: 100 },
batteryConfigured: Boolean,
batterySoc: Number,
batteryMode: String as PropType<BATTERY_MODE>,
// session
sessionEnergy: Number,

View file

@ -31,6 +31,7 @@
:pvConfigured="pvConfigured"
:batteryConfigured="batteryConfigured"
:batterySoc="batterySoc"
:batteryMode="batteryMode"
:forecast="forecast"
class="h-100"
:class="{ 'loadpoint-unselected': !selected(loadpoint.id) }"
@ -67,7 +68,7 @@ import "@h2d2/shopicons/es/filled/lightning";
import Loadpoint from "./Loadpoint.vue";
import { defineComponent, type PropType } from "vue";
import type { UiLoadpoint, SMART_COST_TYPE, Timeout, Vehicle } from "@/types/evcc";
import type { UiLoadpoint, SMART_COST_TYPE, Timeout, Vehicle, BATTERY_MODE } from "@/types/evcc";
export default defineComponent({
name: "Loadpoints",
@ -87,6 +88,7 @@ export default defineComponent({
pvConfigured: Boolean,
batteryConfigured: Boolean,
batterySoc: Number,
batteryMode: String as PropType<BATTERY_MODE>,
forecast: Object, // as PropType<Forecast>,
},
emits: ["id-changed"],

View file

@ -69,6 +69,7 @@
:pvConfigured="pvConfigured"
:batteryConfigured="batteryConfigured"
:batterySoc="batterySoc"
:batteryMode="batteryMode"
:forecast="forecast"
:selectedId="selectedLoadpointId"
@id-changed="selectedLoadpointChanged"
@ -101,6 +102,7 @@ import type {
Sponsor,
FatalError,
EvOpt,
BATTERY_MODE,
} from "@/types/evcc";
import store from "@/store";
import type { Grid } from "./types";
@ -134,7 +136,7 @@ export default defineComponent({
batteryDischargeControl: Boolean,
batteryGridChargeLimit: { type: [Number, null] as PropType<number | null>, default: null },
batteryGridChargeActive: Boolean,
batteryMode: String,
batteryMode: String as PropType<BATTERY_MODE>,
battery: { type: Object as PropType<Battery> },
gridCurrents: Array,
prioritySoc: Number,

View file

@ -88,7 +88,13 @@ import LimitSocSelect from "./LimitSocSelect.vue";
import LimitEnergySelect from "./LimitEnergySelect.vue";
import { distanceUnit, distanceValue } from "@/units.ts";
import { defineComponent, type PropType } from "vue";
import { CHARGE_MODE, type Forecast, type VehicleStatus, type Vehicle } from "@/types/evcc";
import {
CHARGE_MODE,
type BATTERY_MODE,
type Forecast,
type VehicleStatus,
type Vehicle,
} from "@/types/evcc";
import type { PlanStrategy } from "@/components/ChargingPlans/types";
import BatteryBoostButton from "../Loadpoints/BatteryBoostButton.vue";
@ -121,6 +127,7 @@ export default defineComponent({
batteryBoostAvailable: Boolean,
batteryBoostLimit: { type: Number, default: 100 },
batterySoc: Number,
batteryMode: String as PropType<BATTERY_MODE>,
enabled: Boolean,
heating: Boolean,
id: [String, Number],

View file

@ -78,6 +78,7 @@ export interface State {
system?: string;
timezone?: string;
battery?: Battery;
batteryMode?: BATTERY_MODE;
pv?: Meter[];
aux?: Meter[];
ext?: Meter[];
@ -377,6 +378,13 @@ export enum CHARGE_MODE {
PV = "pv",
}
export enum BATTERY_MODE {
UNKNOWN = "unknown",
NORMAL = "normal",
HOLD = "hold",
CHARGE = "charge",
}
export enum PHASES {
AUTO = 0,
ONE_PHASE = 1,

View file

@ -1156,6 +1156,7 @@
"once": "Boost ist für diesen Ladevorgang aktiviert.",
"stateActive": "Batterie Boost aktiv",
"stateBelowLimit": "Batterie zu niedrig für Boost",
"stateHold": "Batterie gesperrt",
"stateReady": "Batterie Boost bereit"
},
"batteryUsage": "Hausbatterie",
@ -1265,6 +1266,7 @@
"batteryBoostBelowLimit": "Batterie zu niedrig für Boost.",
"batteryBoostDisabled": "Batterie Boost deaktiviert.",
"batteryBoostEnabled": "Boost bis Batterie bei {limit}.",
"batteryBoostHold": "Batterie gesperrt. Boost nicht verfügbar.",
"charging": "Ladevorgang aktiv …",
"cheapEnergyCharging": "Günstige Energie verfügbar.",
"cheapEnergyNextStart": "Günstige Energie in {duration}.",

View file

@ -1159,6 +1159,7 @@
"once": "Boost active for this charging session.",
"stateActive": "Battery boost active",
"stateBelowLimit": "Battery too low for boost",
"stateHold": "Battery locked",
"stateReady": "Battery boost ready"
},
"batteryUsage": "Home Battery",
@ -1268,6 +1269,7 @@
"batteryBoostBelowLimit": "Battery too low for boost.",
"batteryBoostDisabled": "Battery boost disabled.",
"batteryBoostEnabled": "Boost until battery at {limit}.",
"batteryBoostHold": "Battery locked. Boost not available.",
"charging": "Charging…",
"cheapEnergyCharging": "Cheap energy available.",
"cheapEnergyNextStart": "Cheap energy in {duration}.",

View file

@ -1089,7 +1089,7 @@
},
"/loadpoints/{id}/batteryboost/{enable}": {
"post": {
"description": "Enable or disable battery boost. When active, the maximum available home battery power is added until the home battery is drained to configured SoC limit.",
"description": "Enable or disable battery boost. When active, the maximum available home battery power is added until the home battery is drained to configured SoC limit. Note: boost will not work while the battery is on hold (e.g. during fast charging or planned charging with discharge prevention enabled).",
"externalDocs": {
"url": "https://docs.evcc.io/en/docs/features/battery#battery-boost"
},

View file

@ -482,7 +482,7 @@ call removeLoadpointVehicle {
## setLoadpointBatteryBoost
Enable or disable battery boost. When active, the maximum available home battery power is added until the home battery is drained to configured SoC limit.
Enable or disable battery boost. When active, the maximum available home battery power is added until the home battery is drained to configured SoC limit. Note: boost will not work while the battery is on hold (e.g. during fast charging or planned charging with discharge prevention enabled).
**Tags:** loadpoints

View file

@ -192,7 +192,7 @@ paths:
post:
operationId: setLoadpointBatteryBoost
summary: Set battery boost
description: "Enable or disable battery boost. When active, the maximum available home battery power is added until the home battery is drained to configured SoC limit."
description: "Enable or disable battery boost. When active, the maximum available home battery power is added until the home battery is drained to configured SoC limit. Note: boost will not work while the battery is on hold (e.g. during fast charging or planned charging with discharge prevention enabled)."
externalDocs:
url: https://docs.evcc.io/en/docs/features/battery#battery-boost
tags:

View file

@ -37,6 +37,9 @@ loadpoints:
- title: Carport
charger: charger
mode: now
- title: Garage
charger: charger2
mode: off
chargers:
- name: charger
@ -56,6 +59,23 @@ chargers:
source: js
script: |
16
- name: charger2
type: custom
enable:
source: js
script:
enabled:
source: js
script: |
true
status:
source: js
script: |
"C"
maxcurrent:
source: js
script: |
16
tariffs:
currency: EUR

View file

@ -1,6 +1,13 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import { expectModalHidden, newLoadpoint, addDemoCharger, ChargerStatus } from "./utils";
import {
expectModalHidden,
expectModalVisible,
newLoadpoint,
addDemoCharger,
ChargerStatus,
openTopNavigation,
} from "./utils";
test.use({ baseURL: baseUrl() });
const CONFIG_BATTERY = "battery-settings.evcc.yaml";
@ -13,15 +20,12 @@ test.describe("boost", async () => {
test("activate and deactivate boost in solar mode", async ({ page }) => {
await start(CONFIG_BATTERY);
await page.goto("/");
const boostButton = page.getByTestId("battery-boost-button");
const lp = page.getByTestId("loadpoint").first();
const boostButton = lp.getByTestId("battery-boost-button");
await expect(boostButton).not.toBeVisible();
await page
.getByTestId("mode")
.first()
.getByRole("button", { name: "Solar", exact: true })
.click();
await page.getByTestId("loadpoint-settings-button").nth(1).click();
const modal = page.getByTestId("loadpoint-settings-modal");
await lp.getByTestId("mode").getByRole("button", { name: "Solar", exact: true }).click();
await lp.getByTestId("loadpoint-settings-button").last().click();
const modal = page.getByTestId("loadpoint-settings-modal").first();
await modal.getByTestId("battery-boost-limit").selectOption("20 %");
await expect(modal.getByTestId("battery-boost")).toContainText(
"Allow fast charging from home battery until it's drained to 20%."
@ -41,14 +45,11 @@ test.describe("boost", async () => {
test("battery too low for boost when limit above soc", async ({ page }) => {
await start(CONFIG_BATTERY);
await page.goto("/");
const boostButton = page.getByTestId("battery-boost-button");
await page
.getByTestId("mode")
.first()
.getByRole("button", { name: "Solar", exact: true })
.click();
await page.getByTestId("loadpoint-settings-button").nth(1).click();
const modal = page.getByTestId("loadpoint-settings-modal");
const lp = page.getByTestId("loadpoint").first();
const boostButton = lp.getByTestId("battery-boost-button");
await lp.getByTestId("mode").getByRole("button", { name: "Solar", exact: true }).click();
await lp.getByTestId("loadpoint-settings-button").last().click();
const modal = page.getByTestId("loadpoint-settings-modal").first();
await modal.getByTestId("battery-boost-limit").selectOption("90 %");
await expect(modal.getByTestId("battery-boost")).toContainText("drained to 90%");
await page.waitForLoadState("networkidle");
@ -61,29 +62,61 @@ test.describe("boost", async () => {
test("boost button disabled in fast mode", async ({ page }) => {
await start(CONFIG_BATTERY);
await page.goto("/");
const boostButton = page.getByTestId("battery-boost-button");
const lp = page.getByTestId("loadpoint").first();
const boostButton = lp.getByTestId("battery-boost-button");
// set a boost limit in solar mode so the boost button appears
await page
.getByTestId("mode")
.first()
.getByRole("button", { name: "Solar", exact: true })
.click();
await page.getByTestId("loadpoint-settings-button").nth(1).click();
const modal = page.getByTestId("loadpoint-settings-modal");
await lp.getByTestId("mode").getByRole("button", { name: "Solar", exact: true }).click();
await lp.getByTestId("loadpoint-settings-button").last().click();
const modal = page.getByTestId("loadpoint-settings-modal").first();
await modal.getByTestId("battery-boost-limit").selectOption("20 %");
await expect(modal.getByTestId("battery-boost")).toContainText("drained to 20%");
await page.waitForLoadState("networkidle");
await modal.getByLabel("Close").click();
await expectModalHidden(modal);
// switch to fast mode and verify boost button is disabled
await page.getByTestId("mode").first().getByRole("button", { name: "Fast" }).click();
await lp.getByTestId("mode").getByRole("button", { name: "Fast" }).click();
await expect(boostButton).toBeDisabled();
});
test("boost button disabled when battery is on hold", async ({ page }) => {
await start(CONFIG_BATTERY);
await page.goto("/");
// LP1: set solar mode, configure boost limit
const lp1 = page.getByTestId("loadpoint").first();
await lp1.getByTestId("mode").getByRole("button", { name: "Solar", exact: true }).click();
await lp1.getByTestId("loadpoint-settings-button").last().click();
const modal = page.getByTestId("loadpoint-settings-modal").first();
await modal.getByTestId("battery-boost-limit").selectOption("0 %");
await modal.getByLabel("Close").click();
await expectModalHidden(modal);
// enable "Prevent discharge in fast mode"
await openTopNavigation(page);
await page.getByTestId("topnavigation-battery").click();
const batteryModal = page.getByTestId("battery-settings-modal");
await expectModalVisible(batteryModal);
await batteryModal.getByLabel("Prevent discharge in fast mode and planned charging.").check();
await page.getByRole("button", { name: "Close" }).click();
await expectModalHidden(batteryModal);
// LP2: switch to fast mode → triggers global battery hold
const lp2 = page.getByTestId("loadpoint").nth(1);
await lp2.getByTestId("mode").getByRole("button", { name: "Fast" }).click();
// LP1: boost button should show hold state
const boostButton = lp1.getByTestId("battery-boost-button");
await expect(boostButton).toHaveAttribute("aria-label", "Battery locked");
// clicking should not change state
await boostButton.click();
await expect(boostButton).toHaveAttribute("aria-label", "Battery locked");
});
test("boost default for ui-created loadpoint", async ({ page }) => {
await start(CONFIG_BATTERY);
// create a second loadpoint
// create a third loadpoint
await page.goto("/#/config");
await newLoadpoint(page, "New Charger");
await addDemoCharger(page, undefined, ChargerStatus.Connected);
@ -94,7 +127,7 @@ test.describe("boost", async () => {
// restart evcc to apply new loadpoint
await restart(CONFIG_BATTERY);
await page.goto("/");
await expect(page.getByTestId("loadpoint")).toHaveCount(2);
await expect(page.getByTestId("loadpoint")).toHaveCount(3);
const lp = page.getByTestId("loadpoint").last();