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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1089,7 +1089,7 @@
}, },
"/loadpoints/{id}/batteryboost/{enable}": { "/loadpoints/{id}/batteryboost/{enable}": {
"post": { "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": { "externalDocs": {
"url": "https://docs.evcc.io/en/docs/features/battery#battery-boost" "url": "https://docs.evcc.io/en/docs/features/battery#battery-boost"
}, },

View file

@ -482,7 +482,7 @@ call removeLoadpointVehicle {
## setLoadpointBatteryBoost ## 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 **Tags:** loadpoints

View file

@ -192,7 +192,7 @@ paths:
post: post:
operationId: setLoadpointBatteryBoost operationId: setLoadpointBatteryBoost
summary: Set battery boost 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: externalDocs:
url: https://docs.evcc.io/en/docs/features/battery#battery-boost url: https://docs.evcc.io/en/docs/features/battery#battery-boost
tags: tags:

View file

@ -37,6 +37,9 @@ loadpoints:
- title: Carport - title: Carport
charger: charger charger: charger
mode: now mode: now
- title: Garage
charger: charger2
mode: off
chargers: chargers:
- name: charger - name: charger
@ -56,6 +59,23 @@ chargers:
source: js source: js
script: | script: |
16 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: tariffs:
currency: EUR currency: EUR

View file

@ -1,6 +1,13 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc"; 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() }); test.use({ baseURL: baseUrl() });
const CONFIG_BATTERY = "battery-settings.evcc.yaml"; 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 }) => { test("activate and deactivate boost in solar mode", async ({ page }) => {
await start(CONFIG_BATTERY); await start(CONFIG_BATTERY);
await page.goto("/"); 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 expect(boostButton).not.toBeVisible();
await page await lp.getByTestId("mode").getByRole("button", { name: "Solar", exact: true }).click();
.getByTestId("mode") await lp.getByTestId("loadpoint-settings-button").last().click();
.first() const modal = page.getByTestId("loadpoint-settings-modal").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 modal.getByTestId("battery-boost-limit").selectOption("20 %"); await modal.getByTestId("battery-boost-limit").selectOption("20 %");
await expect(modal.getByTestId("battery-boost")).toContainText( await expect(modal.getByTestId("battery-boost")).toContainText(
"Allow fast charging from home battery until it's drained to 20%." "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 }) => { test("battery too low for boost when limit above soc", async ({ page }) => {
await start(CONFIG_BATTERY); await start(CONFIG_BATTERY);
await page.goto("/"); await page.goto("/");
const boostButton = page.getByTestId("battery-boost-button"); const lp = page.getByTestId("loadpoint").first();
await page const boostButton = lp.getByTestId("battery-boost-button");
.getByTestId("mode") await lp.getByTestId("mode").getByRole("button", { name: "Solar", exact: true }).click();
.first() await lp.getByTestId("loadpoint-settings-button").last().click();
.getByRole("button", { name: "Solar", exact: true }) const modal = page.getByTestId("loadpoint-settings-modal").first();
.click();
await page.getByTestId("loadpoint-settings-button").nth(1).click();
const modal = page.getByTestId("loadpoint-settings-modal");
await modal.getByTestId("battery-boost-limit").selectOption("90 %"); await modal.getByTestId("battery-boost-limit").selectOption("90 %");
await expect(modal.getByTestId("battery-boost")).toContainText("drained to 90%"); await expect(modal.getByTestId("battery-boost")).toContainText("drained to 90%");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
@ -61,29 +62,61 @@ test.describe("boost", async () => {
test("boost button disabled in fast mode", async ({ page }) => { test("boost button disabled in fast mode", async ({ page }) => {
await start(CONFIG_BATTERY); await start(CONFIG_BATTERY);
await page.goto("/"); 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 // set a boost limit in solar mode so the boost button appears
await page await lp.getByTestId("mode").getByRole("button", { name: "Solar", exact: true }).click();
.getByTestId("mode") await lp.getByTestId("loadpoint-settings-button").last().click();
.first() const modal = page.getByTestId("loadpoint-settings-modal").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 modal.getByTestId("battery-boost-limit").selectOption("20 %"); await modal.getByTestId("battery-boost-limit").selectOption("20 %");
await expect(modal.getByTestId("battery-boost")).toContainText("drained to 20%"); await expect(modal.getByTestId("battery-boost")).toContainText("drained to 20%");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await modal.getByLabel("Close").click(); await modal.getByLabel("Close").click();
await expectModalHidden(modal); await expectModalHidden(modal);
// switch to fast mode and verify boost button is disabled // 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(); 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 }) => { test("boost default for ui-created loadpoint", async ({ page }) => {
await start(CONFIG_BATTERY); await start(CONFIG_BATTERY);
// create a second loadpoint // create a third loadpoint
await page.goto("/#/config"); await page.goto("/#/config");
await newLoadpoint(page, "New Charger"); await newLoadpoint(page, "New Charger");
await addDemoCharger(page, undefined, ChargerStatus.Connected); await addDemoCharger(page, undefined, ChargerStatus.Connected);
@ -94,7 +127,7 @@ test.describe("boost", async () => {
// restart evcc to apply new loadpoint // restart evcc to apply new loadpoint
await restart(CONFIG_BATTERY); await restart(CONFIG_BATTERY);
await page.goto("/"); await page.goto("/");
await expect(page.getByTestId("loadpoint")).toHaveCount(2); await expect(page.getByTestId("loadpoint")).toHaveCount(3);
const lp = page.getByTestId("loadpoint").last(); const lp = page.getByTestId("loadpoint").last();