From 0046661568f9d843f3f6a14bb5a861aa91eba8df Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 17 Feb 2026 12:40:02 +0100 Subject: [PATCH] Battery Boost Button & Limit (BC) (#27392) --- assets/css/app.css | 2 + .../Loadpoints/BatteryBoostButton.stories.ts | 78 ++++++ .../Loadpoints/BatteryBoostButton.vue | 259 ++++++++++++++++++ assets/js/components/Loadpoints/Loadpoint.vue | 11 +- .../js/components/Loadpoints/Loadpoints.vue | 2 + .../Loadpoints/SettingsBatteryBoost.vue | 93 ++++--- .../components/Loadpoints/SettingsModal.vue | 10 +- .../components/MaterialIcon/BatteryBoost.vue | 45 ++- .../js/components/Vehicles/LimitSocSelect.vue | 2 + assets/js/components/Vehicles/Status.vue | 51 +++- assets/js/components/Vehicles/Vehicle.vue | 44 ++- assets/js/types/evcc.ts | 5 + cmd/demo.yaml | 4 +- core/keys/loadpoint.go | 41 +-- core/loadpoint.go | 34 ++- core/loadpoint/api.go | 4 + core/loadpoint/config.go | 2 + core/loadpoint/mock.go | 26 ++ core/loadpoint_api.go | 21 ++ core/site/api.go | 1 + core/site_api.go | 7 + i18n/bg.json | 2 +- i18n/cs.json | 2 +- i18n/da.json | 2 +- i18n/de.json | 12 +- i18n/el.json | 2 +- i18n/en.json | 12 +- i18n/es.json | 2 +- i18n/fi.json | 2 +- i18n/fr.json | 2 +- i18n/hr.json | 2 +- i18n/hu.json | 2 +- i18n/it.json | 2 +- i18n/lb.json | 2 +- i18n/lt.json | 2 +- i18n/nl.json | 2 +- i18n/no.json | 2 +- i18n/pl.json | 2 +- i18n/pt.json | 2 +- i18n/ro.json | 2 +- i18n/ru.json | 2 +- i18n/sl.json | 2 +- i18n/sv.json | 2 +- i18n/ta.json | 2 +- i18n/tr.json | 2 +- i18n/uk.json | 2 +- i18n/zh-Hans.json | 2 +- server/http.go | 1 + server/http_config_loadpoint_handler.go | 1 + server/mcp/openapi.json | 28 +- server/mcp/openapi.md | 24 +- server/mqtt.go | 1 + server/openapi.yaml | 17 +- tests/boot.spec.ts | 65 +++-- 54 files changed, 805 insertions(+), 146 deletions(-) create mode 100644 assets/js/components/Loadpoints/BatteryBoostButton.stories.ts create mode 100644 assets/js/components/Loadpoints/BatteryBoostButton.vue diff --git a/assets/css/app.css b/assets/css/app.css index 9a586c4e0..1646e8b07 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -68,6 +68,7 @@ --bs-primary: var(--evcc-darker-green); --bs-primary-rgb: var(--evcc-darker-green-rgb); + --bs-primary-dim: color-mix(in srgb, var(--bs-primary) 50%, transparent); --bs-warning: var(--evcc-orange); --bs-warning-rgb: var(--evcc-orange-rgb); @@ -106,6 +107,7 @@ --evcc-accent2: var(--evcc-dark-green); --evcc-accent3: var(--evcc-darker-green); --bs-primary: var(--evcc-dark-green); + --bs-primary-dim: color-mix(in srgb, var(--bs-primary) 70%, transparent); --bs-border-color-translucent: rgba(255, 255, 255, 0.175); --bs-code-color: var(--evcc-dark-green); --evcc-backdrop: #000000aa; /* black + transparent */ diff --git a/assets/js/components/Loadpoints/BatteryBoostButton.stories.ts b/assets/js/components/Loadpoints/BatteryBoostButton.stories.ts new file mode 100644 index 000000000..63fd0e4a7 --- /dev/null +++ b/assets/js/components/Loadpoints/BatteryBoostButton.stories.ts @@ -0,0 +1,78 @@ +import BatteryBoostButton from "./BatteryBoostButton.vue"; +import { CHARGE_MODE } from "@/types/evcc"; +import type { Meta, StoryFn } from "@storybook/vue3"; + +export default { + title: "Loadpoints/BatteryBoostButton", + component: BatteryBoostButton, + argTypes: { + batteryBoost: { control: "boolean" }, + batteryBoostLimit: { control: { type: "range", min: 0, max: 100, step: 5 } }, + batterySoc: { control: { type: "range", min: 0, max: 100, step: 5 } }, + mode: { control: "select", options: Object.values(CHARGE_MODE) }, + }, + parameters: { + layout: "centered", + }, +} as Meta; + +const socLevels = [0, 20, 40, 60, 80, 100]; + +interface RowConfig { + label: string; + batteryBoost: boolean; + batteryBoostLimit: number; + mode: CHARGE_MODE; +} + +const rows: RowConfig[] = [ + { label: "Ready (limit 0)", batteryBoost: false, batteryBoostLimit: 0, mode: CHARGE_MODE.PV }, + { label: "Enabled (limit 0)", batteryBoost: true, batteryBoostLimit: 0, mode: CHARGE_MODE.PV }, + { label: "Ready (limit 50)", batteryBoost: false, batteryBoostLimit: 50, mode: CHARGE_MODE.PV }, + { label: "Enabled (limit 50)", batteryBoost: true, batteryBoostLimit: 50, mode: CHARGE_MODE.PV }, + { label: "Disabled", batteryBoost: false, batteryBoostLimit: 50, mode: CHARGE_MODE.OFF }, +]; + +export const AllStates: StoryFn = () => ({ + components: { BatteryBoostButton }, + setup() { + return { socLevels, rows }; + }, + template: ` +
+
+
+
{{ soc }}%
+ +
+
+ `, +}); +AllStates.storyName = "All States (grid overview)"; + +const Template: StoryFn = (args) => ({ + components: { BatteryBoostButton }, + setup() { + return { args }; + }, + template: '', +}); + +export const Playground = Template.bind({}); +Playground.args = { + batteryBoost: false, + batteryBoostLimit: 50, + batterySoc: 75, + mode: CHARGE_MODE.PV, +}; diff --git a/assets/js/components/Loadpoints/BatteryBoostButton.vue b/assets/js/components/Loadpoints/BatteryBoostButton.vue new file mode 100644 index 000000000..343df0d6f --- /dev/null +++ b/assets/js/components/Loadpoints/BatteryBoostButton.vue @@ -0,0 +1,259 @@ + + + + + diff --git a/assets/js/components/Loadpoints/Loadpoint.vue b/assets/js/components/Loadpoints/Loadpoint.vue index 822d10c67..aa914c5ba 100644 --- a/assets/js/components/Loadpoints/Loadpoint.vue +++ b/assets/js/components/Loadpoints/Loadpoint.vue @@ -37,7 +37,7 @@ @maxcurrent-updated="setMaxCurrent" @mincurrent-updated="setMinCurrent" @phasesconfigured-updated="setPhasesConfigured" - @batteryboost-updated="setBatteryBoost" + @batteryboostlimit-updated="setBatteryBoostLimit" />
@@ -156,7 +157,9 @@ export default defineComponent({ chargeDuration: { type: Number, default: 0 }, charging: Boolean, batteryBoost: Boolean, + batteryBoostLimit: { type: Number, default: 100 }, batteryConfigured: Boolean, + batterySoc: Number, // session sessionEnergy: Number, @@ -316,7 +319,8 @@ export default defineComponent({ this.batteryBoost && this.charging && this.mode && - !["off", "now"].includes(this.mode) + !["off", "now"].includes(this.mode) && + (this.batterySoc ?? 0) >= this.batteryBoostLimit ); }, plannerForecast() { @@ -390,6 +394,9 @@ export default defineComponent({ setBatteryBoost(batteryBoost: boolean) { api.post(this.apiPath("batteryboost") + `/${batteryBoost ? "1" : "0"}`); }, + setBatteryBoostLimit(limit: number) { + api.post(this.apiPath("batteryboostlimit") + "/" + limit); + }, fmtPower(value: number) { return this.fmtW(value, POWER_UNIT.AUTO); }, diff --git a/assets/js/components/Loadpoints/Loadpoints.vue b/assets/js/components/Loadpoints/Loadpoints.vue index 506d32782..07411beec 100644 --- a/assets/js/components/Loadpoints/Loadpoints.vue +++ b/assets/js/components/Loadpoints/Loadpoints.vue @@ -30,6 +30,7 @@ :gridConfigured="gridConfigured" :pvConfigured="pvConfigured" :batteryConfigured="batteryConfigured" + :batterySoc="batterySoc" :forecast="forecast" class="h-100" :class="{ 'loadpoint-unselected': !selected(loadpoint.id) }" @@ -85,6 +86,7 @@ export default defineComponent({ gridConfigured: Boolean, pvConfigured: Boolean, batteryConfigured: Boolean, + batterySoc: Number, forecast: Object, // as PropType, }, emits: ["id-changed"], diff --git a/assets/js/components/Loadpoints/SettingsBatteryBoost.vue b/assets/js/components/Loadpoints/SettingsBatteryBoost.vue index 659f74efe..94ba9eafd 100644 --- a/assets/js/components/Loadpoints/SettingsBatteryBoost.vue +++ b/assets/js/components/Loadpoints/SettingsBatteryBoost.vue @@ -5,33 +5,24 @@
-
@@ -40,34 +31,62 @@ + + diff --git a/assets/js/components/Vehicles/LimitSocSelect.vue b/assets/js/components/Vehicles/LimitSocSelect.vue index d1f58122e..42eaf038f 100644 --- a/assets/js/components/Vehicles/LimitSocSelect.vue +++ b/assets/js/components/Vehicles/LimitSocSelect.vue @@ -83,6 +83,7 @@ export default defineComponent({ diff --git a/assets/js/components/Vehicles/Status.vue b/assets/js/components/Vehicles/Status.vue index 960cd0561..1af71f352 100644 --- a/assets/js/components/Vehicles/Status.vue +++ b/assets/js/components/Vehicles/Status.vue @@ -4,7 +4,11 @@ style="min-height: 24px" data-testid="vehicle-status" > -
+
{{ chargerStatus }}
@@ -57,7 +61,7 @@ import { DEFAULT_LOCALE } from "@/i18n.ts"; import formatter from "@/mixins/formatter"; import minuteTicker from "@/mixins/minuteTicker"; import { defineComponent, type PropType } from "vue"; -import { SMART_COST_TYPE, type CURRENCY } from "@/types/evcc"; +import { SMART_COST_TYPE, type CURRENCY, type VehicleStatus, type Timeout } from "@/types/evcc"; import ClimaterIcon from "../MaterialIcon/Climater.vue"; import DynamicPriceIcon from "../MaterialIcon/DynamicPrice.vue"; @@ -73,7 +77,7 @@ import VehicleLimitReachedIcon from "../MaterialIcon/VehicleLimitReached.vue"; import VehicleLimitWarningIcon from "../MaterialIcon/VehicleLimitWarning.vue"; import VehicleMinSocIcon from "../MaterialIcon/VehicleMinSoc.vue"; import WelcomeIcon from "../MaterialIcon/Welcome.vue"; -import BatteryBoostIcon from "../MaterialIcon/BatteryBoost.vue"; + import SunPauseIcon from "../MaterialIcon/SunPause.vue"; import StatusItem from "./StatusItem.vue"; @@ -89,7 +93,7 @@ export default defineComponent({ mixins: [formatter, minuteTicker], props: { vehicleSoc: { type: Number, default: 0 }, - batteryBoostActive: Boolean, + charging: Boolean, chargingPlanDisabled: Boolean, chargerStatusReason: String, @@ -125,8 +129,15 @@ export default defineComponent({ vehicleClimaterActive: Boolean, vehicleWelcomeActive: Boolean, vehicleLimitSoc: { type: Number, default: 0 }, + statusOverride: { type: Object as PropType, default: undefined }, }, emits: ["open-loadpoint-settings", "open-minsoc-settings", "open-plan-modal"], + data() { + return { + statusOverrideActive: false, + statusTimeout: null as Timeout | null, + }; + }, computed: { pvTimerActive() { return ( @@ -180,7 +191,16 @@ export default defineComponent({ smartFeedInPriorityLimitFmt() { return this.fmtPricePerKWh(this.smartFeedInPriorityLimit, this.currency, true); }, + chargerStatusType(): string | undefined { + if (this.statusOverrideActive && this.statusOverride) { + return this.statusOverride.type; + } + return undefined; + }, chargerStatus() { + if (this.statusOverrideActive && this.statusOverride) { + return this.statusOverride.message; + } const t = (key: string) => { if (this.heating) { // check for special heating status translation @@ -341,15 +361,6 @@ export default defineComponent({ clickable: true, clickHandler: () => this.openLoadpointSettings(), }, - { - id: "batteryBoost", - visible: this.batteryBoostActive, - tooltipContent: t("batteryBoost"), - iconComponent: BatteryBoostIcon, - testId: "vehicle-status-batteryboost", - clickable: true, - clickHandler: () => this.openLoadpointSettings(), - }, { id: "planActive", visible: this.planProjectedEnd && this.planActive && !this.chargingPlanDisabled, @@ -393,6 +404,20 @@ export default defineComponent({ return items.filter((item) => item.visible); }, }, + watch: { + statusOverride(val: VehicleStatus | undefined) { + if (val) { + this.statusOverrideActive = true; + if (this.statusTimeout) clearTimeout(this.statusTimeout); + this.statusTimeout = setTimeout(() => { + this.statusOverrideActive = false; + }, 2500); + } + }, + }, + unmounted() { + if (this.statusTimeout) clearTimeout(this.statusTimeout); + }, methods: { openLoadpointSettings() { this.$emit("open-loadpoint-settings"); diff --git a/assets/js/components/Vehicles/Vehicle.vue b/assets/js/components/Vehicles/Vehicle.vue index 24e69056b..3fca14fa2 100644 --- a/assets/js/components/Vehicles/Vehicle.vue +++ b/assets/js/components/Vehicles/Vehicle.vue @@ -13,13 +13,22 @@ @open-minsoc-settings="openMinSocSettings" @open-plan-modal="openPlanModal" /> - +
+ + +
, + batteryBoost: Boolean, batteryBoostActive: Boolean, + batteryBoostAvailable: Boolean, + batteryBoostLimit: { type: Number, default: 100 }, + batterySoc: Number, enabled: Boolean, heating: Boolean, id: [String, Number], @@ -153,10 +168,12 @@ export default defineComponent({ "change-vehicle", "remove-vehicle", "open-loadpoint-settings", + "batteryboost-updated", ], data() { return { displayLimitSoc: this.effectiveLimitSoc, + statusOverride: undefined as VehicleStatus | undefined, }; }, computed: { @@ -176,7 +193,7 @@ export default defineComponent({ return this.collectProps(Soc); }, vehicleStatus() { - return this.collectProps(Status); + return { ...this.collectProps(Status), statusOverride: this.statusOverride }; }, vehicleTitleProps() { return this.collectProps(Title); @@ -184,6 +201,12 @@ export default defineComponent({ chargingPlan() { return this.collectProps(ChargingPlan); }, + showBoostButton(): boolean { + return this.batteryBoostAvailable && this.batteryBoostLimit < 100; + }, + batteryBoostButtonProps() { + return this.collectProps(BatteryBoostButton); + }, formattedSoc() { if (!this.vehicleSoc) { return "--"; @@ -261,6 +284,9 @@ export default defineComponent({ this.$refs["chargingPlan"] as InstanceType | undefined )?.openPlanModal(); }, + handleBoostStatus(status: VehicleStatus) { + this.statusOverride = status; + }, openMinSocSettings() { ( this.$refs["chargingPlan"] as InstanceType | undefined diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index d3020d675..7963caea8 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -538,6 +538,11 @@ export interface Vehicle { export type Timeout = ReturnType | null; +export interface VehicleStatus { + message: string; + type?: string; +} + export interface Tariff { rates: Rate[]; lastUpdate: Date; diff --git a/cmd/demo.yaml b/cmd/demo.yaml index 5567aa802..dadecffac 100644 --- a/cmd/demo.yaml +++ b/cmd/demo.yaml @@ -68,8 +68,8 @@ meters: vm: shared script: | if (state.batterypower < 0) state.batterySoc++; else state.batterySoc--; - if (state.batterySoc < 10) state.batterySoc = 90; - if (state.batterySoc > 90) state.batterySoc = 10; + if (state.batterySoc < 10) state.batterySoc = 100; + if (state.batterySoc > 100) state.batterySoc = 10; state.batterySoc; capacity: 13.4 batterymode: diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go index cf2e3a221..fb547f1d1 100644 --- a/core/keys/loadpoint.go +++ b/core/keys/loadpoint.go @@ -2,26 +2,27 @@ package keys const ( // loadpoint settings - Title = "title" // loadpoint title - Mode = "mode" // charge mode - DefaultMode = "defaultMode" // default charge mode - Charger = "charger" // charger ref - Meter = "meter" // meter ref - Circuit = "circuit" // circuit ref - DefaultVehicle = "vehicle" // default vehicle ref - Priority = "priority" // priority - MinCurrent = "minCurrent" // min current - MaxCurrent = "maxCurrent" // max current - MinSoc = "minSoc" // min soc - LimitSoc = "limitSoc" // limit soc - LimitEnergy = "limitEnergy" // limit energy - Soc = "soc" - Thresholds = "thresholds" - EnableThreshold = "enableThreshold" - DisableThreshold = "disableThreshold" - EnableDelay = "enableDelay" - DisableDelay = "disableDelay" - BatteryBoost = "batteryBoost" + Title = "title" // loadpoint title + Mode = "mode" // charge mode + DefaultMode = "defaultMode" // default charge mode + Charger = "charger" // charger ref + Meter = "meter" // meter ref + Circuit = "circuit" // circuit ref + DefaultVehicle = "vehicle" // default vehicle ref + Priority = "priority" // priority + MinCurrent = "minCurrent" // min current + MaxCurrent = "maxCurrent" // max current + MinSoc = "minSoc" // min soc + LimitSoc = "limitSoc" // limit soc + LimitEnergy = "limitEnergy" // limit energy + Soc = "soc" + Thresholds = "thresholds" + EnableThreshold = "enableThreshold" + DisableThreshold = "disableThreshold" + EnableDelay = "enableDelay" + DisableDelay = "disableDelay" + BatteryBoost = "batteryBoost" + BatteryBoostLimit = "batteryBoostLimit" PhasesConfigured = "phasesConfigured" // desired phase mode (0/1/3, 0 = automatic), user selection PhasesActive = "phasesActive" // expectedly active phases, taking vehicle into account (1/2/3) diff --git a/core/loadpoint.go b/core/loadpoint.go index a166c9a02..8d85af6fe 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -118,6 +118,7 @@ type Loadpoint struct { smartCostLimit *float64 // always charge if consumption cost is below this value smartFeedInPriorityLimit *float64 // prevent charging if feed-in cost is above this value batteryBoost int // battery boost state + batteryBoostLimit int // battery boost soc limit (0-100, 100=disabled) mode api.ChargeMode enabled bool // Charger enabled state @@ -286,14 +287,15 @@ func NewLoadpoint(log *util.Logger, settings settings.Settings) *Loadpoint { bus := evbus.New() lp := &Loadpoint{ - log: log, // logger - settings: settings, // settings - clock: clock, // mockable time - bus: bus, // event bus - mode: api.ModeOff, - status: api.StatusNone, - minCurrent: 6, // A - maxCurrent: 16, // A + log: log, // logger + settings: settings, // settings + clock: clock, // mockable time + bus: bus, // event bus + mode: api.ModeOff, + status: api.StatusNone, + minCurrent: 6, // A + maxCurrent: 16, // A + batteryBoostLimit: 100, // disabled Soc: loadpoint.SocConfig{ Poll: loadpoint.PollConfig{ Interval: pollInterval, @@ -358,6 +360,9 @@ func (lp *Loadpoint) restoreSettings() { if v, err := lp.settings.Float(keys.SmartFeedInPriorityLimit); err == nil { lp.SetSmartFeedInPriorityLimit(&v) } + if v, err := lp.settings.Int(keys.BatteryBoostLimit); err == nil { + lp.SetBatteryBoostLimit(int(v)) + } var thresholds loadpoint.ThresholdsConfig if err := lp.settings.Json(keys.Thresholds, &thresholds); err == nil { @@ -699,6 +704,7 @@ func (lp *Loadpoint) Prepare(site site.API, uiChan chan<- util.Param, pushChan c // battery boost lp.publish(keys.BatteryBoost, lp.batteryBoost != boostDisabled) + lp.publish(keys.BatteryBoostLimit, lp.batteryBoostLimit) // read initial charger state to prevent immediately disabling charger if enabled, err := lp.charger.Enabled(); err == nil { @@ -1860,6 +1866,18 @@ func (lp *Loadpoint) phaseSwitchCompleted() bool { // Update is the main control function. It reevaluates meters and charger state func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, consumption, feedin api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effPrice, effCo2 *float64) { + // auto-disable battery boost when SOC drops below limit + if lp.GetBatteryBoost() != boostDisabled { + limit := lp.GetBatteryBoostLimit() + if limit < 100 { + batterySoc := lp.site.GetBatterySoc() + if batterySoc < float64(limit) { + lp.log.DEBUG.Printf("battery boost disabled: soc below limit (%.0f%% < %d%%)", batterySoc, limit) + _ = lp.SetBatteryBoost(false) + } + } + } + // smart cost smartCostActive, smartCostNextStart := lp.checkSmartLimit(lp.GetSmartCostLimit(), consumption, true) lp.publish(keys.SmartCostActive, smartCostActive) diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index 53695d534..adc632470 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -167,6 +167,10 @@ type API interface { GetBatteryBoost() int // SetBatteryBoost sets the battery boost SetBatteryBoost(enable bool) error + // GetBatteryBoostLimit returns the battery boost soc limit + GetBatteryBoostLimit() int + // SetBatteryBoostLimit sets the battery boost soc limit + SetBatteryBoostLimit(int) // // smart grid charging diff --git a/core/loadpoint/config.go b/core/loadpoint/config.go index 8a307e460..b2bf0d242 100644 --- a/core/loadpoint/config.go +++ b/core/loadpoint/config.go @@ -28,6 +28,7 @@ type DynamicConfig struct { PlanEnergy float64 `json:"planEnergy"` PlanTime time.Time `json:"planTime"` PlanPrecondition_ int64 `json:"planPrecondition" mapstructure:"planPrecondition"` // TODO deprecated, keep for compatibility + BatteryBoostLimit int `json:"batteryBoostLimit"` LimitEnergy float64 `json:"limitEnergy"` LimitSoc int `json:"limitSoc"` @@ -63,6 +64,7 @@ func (payload DynamicConfig) Apply(lp API) error { lp.SetThresholds(payload.Thresholds) lp.SetPlanEnergy(payload.PlanTime, payload.PlanEnergy) lp.SetPlanStrategy(payload.PlanStrategy) + lp.SetBatteryBoostLimit(payload.BatteryBoostLimit) lp.SetLimitEnergy(payload.LimitEnergy) lp.SetLimitSoc(payload.LimitSoc) diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index dc81b3683..b02961aae 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -179,6 +179,20 @@ func (mr *MockAPIMockRecorder) GetBatteryBoost() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBatteryBoost", reflect.TypeOf((*MockAPI)(nil).GetBatteryBoost)) } +// GetBatteryBoostLimit mocks base method. +func (m *MockAPI) GetBatteryBoostLimit() int { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBatteryBoostLimit") + ret0, _ := ret[0].(int) + return ret0 +} + +// GetBatteryBoostLimit indicates an expected call of GetBatteryBoostLimit. +func (mr *MockAPIMockRecorder) GetBatteryBoostLimit() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBatteryBoostLimit", reflect.TypeOf((*MockAPI)(nil).GetBatteryBoostLimit)) +} + // GetChargePower mocks base method. func (m *MockAPI) GetChargePower() float64 { m.ctrl.T.Helper() @@ -739,6 +753,18 @@ func (mr *MockAPIMockRecorder) SetBatteryBoost(enable any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetBatteryBoost", reflect.TypeOf((*MockAPI)(nil).SetBatteryBoost), enable) } +// SetBatteryBoostLimit mocks base method. +func (m *MockAPI) SetBatteryBoostLimit(arg0 int) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetBatteryBoostLimit", arg0) +} + +// SetBatteryBoostLimit indicates an expected call of SetBatteryBoostLimit. +func (mr *MockAPIMockRecorder) SetBatteryBoostLimit(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetBatteryBoostLimit", reflect.TypeOf((*MockAPI)(nil).SetBatteryBoostLimit), arg0) +} + // SetChargerRef mocks base method. func (m *MockAPI) SetChargerRef(arg0 string) { m.ctrl.T.Helper() diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index fdccd57b0..5b33979e7 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -616,6 +616,27 @@ func (lp *Loadpoint) SetBatteryBoost(enable bool) error { return nil } +// GetBatteryBoostLimit returns the battery boost soc limit +func (lp *Loadpoint) GetBatteryBoostLimit() int { + lp.RLock() + defer lp.RUnlock() + return lp.batteryBoostLimit +} + +// SetBatteryBoostLimit sets the battery boost soc limit +func (lp *Loadpoint) SetBatteryBoostLimit(limit int) { + lp.Lock() + defer lp.Unlock() + + lp.log.DEBUG.Println("set battery boost limit:", limit) + + if lp.batteryBoostLimit != limit { + lp.batteryBoostLimit = limit + lp.settings.SetInt(keys.BatteryBoostLimit, int64(limit)) + lp.publish(keys.BatteryBoostLimit, limit) + } +} + // HasChargeMeter determines if a physical charge meter is attached func (lp *Loadpoint) HasChargeMeter() bool { _, isWrapped := lp.chargeMeter.(*wrapper.ChargeMeter) diff --git a/core/site/api.go b/core/site/api.go index d7c94cf84..2937cf970 100644 --- a/core/site/api.go +++ b/core/site/api.go @@ -41,6 +41,7 @@ type API interface { // battery // + GetBatterySoc() float64 GetPrioritySoc() float64 SetPrioritySoc(float64) error GetBufferSoc() float64 diff --git a/core/site_api.go b/core/site_api.go index 74f3798b8..d6f848a57 100644 --- a/core/site_api.go +++ b/core/site_api.go @@ -132,6 +132,13 @@ func (site *Site) SetExtMeterRefs(ref []string) { settings.SetString(keys.ExtMeters, strings.Join(filterConfigurable(ref), ",")) } +// GetBatterySoc returns the current battery soc +func (site *Site) GetBatterySoc() float64 { + site.RLock() + defer site.RUnlock() + return site.battery.Soc +} + // Loadpoints returns the loadpoints as api interfaces func (site *Site) Loadpoints() []loadpoint.API { return lo.Map(site.loadpoints, func(lp *Loadpoint, _ int) loadpoint.API { return lp }) diff --git a/i18n/bg.json b/i18n/bg.json index 5c2edd710..ef5d23984 100644 --- a/i18n/bg.json +++ b/i18n/bg.json @@ -956,7 +956,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Бързо зареждане от домашна батерия.", + "description": "Бързо зареждане от домашна батерия, докато се изтощи до {limit}.", "label": "Батериен бууст", "mode": "Достъпно само в режим „слънчев“ и „мин+слънчев“.", "once": "Усилване активно за тази сесия на зареждане." diff --git a/i18n/cs.json b/i18n/cs.json index 74088cd93..8c0225e3e 100644 --- a/i18n/cs.json +++ b/i18n/cs.json @@ -981,7 +981,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Povolí využití energie uložené v baterii domu pro nabíjení.", + "description": "Povolí využití energie uložené v baterii domu pro nabíjení, dokud se nevybije na {limit}.", "label": "Rychlé dobití", "mode": "Dostupné pouze v solárním režimu a režimu min+solar.", "once": "Rychlé dobití je aktivováno pro tuto nabíjecí relaci." diff --git a/i18n/da.json b/i18n/da.json index 67f387de1..1bc0e1b0f 100644 --- a/i18n/da.json +++ b/i18n/da.json @@ -983,7 +983,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Hurtig opladning fra husbatteriet.", + "description": "Hurtig opladning fra husbatteriet, indtil det er afladet til {limit}.", "label": "Batteri boost", "mode": "Kun tilgængelig i solar og min+solar mode.", "once": "Boost er aktiveret for denne opladning." diff --git a/i18n/de.json b/i18n/de.json index ec3f170f8..8b10452eb 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1033,10 +1033,15 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Schnell aus Hausbatterie laden.", + "description": "Schnellladen aus der Hausbatterie erlauben, bis sie auf {limit} entladen ist.", + "descriptionDisabled": "Limit wählen, um Schnellladen aus der Hausbatterie zu erlauben.", + "disabled": "Deaktiviert", "label": "Batterie Boost", "mode": "Nur im PV- und Min+PV-Modus verfügbar.", - "once": "Boost ist für diesen Ladevorgang aktiviert." + "once": "Boost ist für diesen Ladevorgang aktiviert.", + "stateActive": "Batterie Boost aktiv", + "stateBelowLimit": "Batterie zu niedrig für Boost", + "stateReady": "Batterie Boost bereit" }, "batteryUsage": "Hausbatterie", "currents": "Ladestrom", @@ -1142,6 +1147,9 @@ "vehicleStatus": { "awaitingAuthorization": "Warte auf Autorisierung.", "batteryBoost": "Batterie Boost aktiv.", + "batteryBoostBelowLimit": "Batterie zu niedrig für Boost.", + "batteryBoostDisabled": "Batterie Boost deaktiviert.", + "batteryBoostEnabled": "Boost bis Batterie bei {limit}.", "charging": "Ladevorgang aktiv …", "cheapEnergyCharging": "Günstige Energie verfügbar.", "cheapEnergyNextStart": "Günstige Energie in {duration}.", diff --git a/i18n/el.json b/i18n/el.json index 8a1e9bdc4..324b74cb7 100644 --- a/i18n/el.json +++ b/i18n/el.json @@ -961,7 +961,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Γρήγορη φόρτιση από οικιακή μπαταρία.", + "description": "Γρήγορη φόρτιση από οικιακή μπαταρία, μέχρι να εκφορτιστεί στο {limit}.", "label": "Ενίσχυση από μπαταρία", "mode": "Διαθέσιμο μόνο σε λειτουργία Φ/Β ή Ελαχ+Φ/Β.", "once": "Ενεργή ενίσχυση για αυτή τη συνεδρία φόρτισης." diff --git a/i18n/en.json b/i18n/en.json index 9562445b5..9fb797246 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1033,10 +1033,15 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Fast charge from home battery.", + "description": "Allow fast charging from home battery until it's drained to {limit}.", + "descriptionDisabled": "Select a limit to allow fast charging from home battery.", + "disabled": "Disabled", "label": "Battery Boost", "mode": "Only available in solar and min+solar mode.", - "once": "Boost active for this charging session." + "once": "Boost active for this charging session.", + "stateActive": "Battery boost active", + "stateBelowLimit": "Battery too low for boost", + "stateReady": "Battery boost ready" }, "batteryUsage": "Home Battery", "currents": "Charging Current", @@ -1142,6 +1147,9 @@ "vehicleStatus": { "awaitingAuthorization": "Waiting for authorization.", "batteryBoost": "Battery boost active.", + "batteryBoostBelowLimit": "Battery too low for boost.", + "batteryBoostDisabled": "Battery boost disabled.", + "batteryBoostEnabled": "Boost until battery at {limit}.", "charging": "Charging…", "cheapEnergyCharging": "Cheap energy available.", "cheapEnergyNextStart": "Cheap energy in {duration}.", diff --git a/i18n/es.json b/i18n/es.json index d3c33a443..b800d8553 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -973,7 +973,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Carga rápida desde la batería doméstica.", + "description": "Carga rápida desde la batería doméstica hasta que se descargue a {limit}.", "label": "Aumento de la batería", "mode": "Solo disponible en modo solar y min+solar.", "once": "Impulso activo para esta sesión de carga." diff --git a/i18n/fi.json b/i18n/fi.json index 59cc8d385..3362978ec 100644 --- a/i18n/fi.json +++ b/i18n/fi.json @@ -980,7 +980,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Nopea lataus kodin akusta.", + "description": "Nopea lataus kodin akusta, kunnes se purkautuu tasolle {limit}.", "label": "Akun tehostaminen", "mode": "Saatavilla vain aurinkosähkö(pv)- ja min + pv-tilassa.", "once": "Tehostus on aktiivinen tälle latausistunnolle." diff --git a/i18n/fr.json b/i18n/fr.json index 296de1efe..1f53c1c44 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -982,7 +982,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Chargement rapide à partir de la batterie domestique.", + "description": "Chargement rapide à partir de la batterie domestique jusqu'à ce qu'elle soit déchargée à {limit}.", "label": "Boost Batterie", "mode": "Uniquement disponible en mode solaire et min+solaire ».", "once": "Boost actif pour cette session de charge." diff --git a/i18n/hr.json b/i18n/hr.json index b7536b52b..85d3c49ad 100644 --- a/i18n/hr.json +++ b/i18n/hr.json @@ -962,7 +962,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Brzo punjenje pomoću kućne baterije.", + "description": "Brzo punjenje pomoću kućne baterije dok se ne isprazni na {limit}.", "label": "Ubrzaj punjenje s baterijom", "mode": "Dostupno samo u modusu 'Solarno' i 'Min+Solarno'.", "once": "Ubrzano punjenje je aktivno za ovu sesiju punjenja." diff --git a/i18n/hu.json b/i18n/hu.json index 1e1a5e036..340de730d 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -961,7 +961,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Gyorstöltés otthoni akkumulátorról.", + "description": "Gyorstöltés otthoni akkumulátorról, amíg az le nem merül {limit} szintre.", "label": "Akkumulátoros Rásegítés", "mode": "Csak szolár és min+szolár módban elérhető.", "once": "Rásegítés aktív ehhez a töltési folyamathoz." diff --git a/i18n/it.json b/i18n/it.json index d76d38338..c45a681e2 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -956,7 +956,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Carica rapida dalla batteria di casa.", + "description": "Carica rapida dalla batteria di casa fino a quando non si scarica a {limit}.", "label": "Boost da batteria", "mode": "Disponibile solo in modalità solare e min+solare.", "once": "Boost attivo per questa sessione di ricarica." diff --git a/i18n/lb.json b/i18n/lb.json index f7e248e0e..1cc293dbe 100644 --- a/i18n/lb.json +++ b/i18n/lb.json @@ -981,7 +981,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Schnell vun der Hausbatterie lueden.", + "description": "Schnell vun der Hausbatterie lueden, bis se op {limit} entlueden ass.", "label": "Batterie Boost", "mode": "Nëmmen am PV- a Min+PV-Modus verfügbar.", "once": "Boost ass dir dës Luedsessioun aktivéiert." diff --git a/i18n/lt.json b/i18n/lt.json index da5a0bf6f..2cbdfd7df 100644 --- a/i18n/lt.json +++ b/i18n/lt.json @@ -983,7 +983,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Greitas įkrovimas iš namų kaupiklio.", + "description": "Greitas įkrovimas iš namų kaupiklio, kol jis išsikraus iki {limit}.", "label": "Kaupikliu paspartintas įkrovimas", "mode": "Galimas tik Saulė ir Min+Saulė režimuose.", "once": "Paspartinimas aktyvuotas šiai įkrovimo sesijai." diff --git a/i18n/nl.json b/i18n/nl.json index b4a36892c..23c9f4def 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -982,7 +982,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Snelladen vanuit thuisbatterij.", + "description": "Snelladen vanuit thuisbatterij tot deze is ontladen naar {limit}.", "label": "Batterij Boost", "mode": "Enkel beschikbaar in PV en Min+PV modus.", "once": "Boost geactiveerd voor deze laadsessie." diff --git a/i18n/no.json b/i18n/no.json index ff7b4008c..fda5727d6 100644 --- a/i18n/no.json +++ b/i18n/no.json @@ -953,7 +953,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Hurtiglading fra hjemmebatteriet.", + "description": "Hurtiglading fra hjemmebatteriet til det er utladet til {limit}.", "label": "Batteriboost", "mode": "Kun tilgjengelig i solenergi- og min+solenergi-modus.", "once": "Boost aktiv for denne ladingsøkten." diff --git a/i18n/pl.json b/i18n/pl.json index af6aa162d..9d29fc91f 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -954,7 +954,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Szybkie ładowanie z domowego magazynu energii.", + "description": "Szybkie ładowanie z domowego magazynu energii, aż rozładuje się do {limit}.", "label": "Doładowanie akumulatora", "mode": "Dostępne tylko w trybie słonce i min+słońce.", "once": "Doładowanie aktywne dla tej sesji ładowania." diff --git a/i18n/pt.json b/i18n/pt.json index 91a19bc4d..57505b8bf 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -981,7 +981,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Carga rápida da bateria de casa.", + "description": "Carga rápida da bateria de casa até que descarregue para {limit}.", "label": "Boost bateria", "mode": "Disponível apenas nos modos solar e min+solar.", "once": "Boost ativo para esta sessão de carga." diff --git a/i18n/ro.json b/i18n/ro.json index 2c7f928be..b68d59228 100644 --- a/i18n/ro.json +++ b/i18n/ro.json @@ -953,7 +953,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Încărcare rapidă de la bateria de acasă.", + "description": "Încărcare rapidă de la bateria de acasă până când se descarcă la {limit}.", "label": "Îmbunătățirea bateriei", "mode": "Disponibil numai în modul solar și min+solar.", "once": "Boost activ pentru această sesiune de încărcare." diff --git a/i18n/ru.json b/i18n/ru.json index 398774067..bf1f132bf 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -952,7 +952,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Быстрая зарядка от домашней батареи.", + "description": "Быстрая зарядка от домашней батареи, пока она не разрядится до {limit}.", "label": "Усиление батареи", "mode": "Доступно только в режимах «Солнечный» и «Мини+солнечный».", "once": "Усиление активно для этой сессии зарядки." diff --git a/i18n/sl.json b/i18n/sl.json index 94a539f4d..70f0ee614 100644 --- a/i18n/sl.json +++ b/i18n/sl.json @@ -983,7 +983,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Hitro polnjenje iz domače baterije.", + "description": "Hitro polnjenje iz domače baterije, dokler se ne izprazni na {limit}.", "label": "Pospeševanje baterije", "mode": "Na voljo samo v solarnem in min+solarnem načinu.", "once": "Pospeševanje z baterijo aktivno za to sejo polnjenja." diff --git a/i18n/sv.json b/i18n/sv.json index a48fecb28..27cec0237 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -983,7 +983,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Snabbladdning från hembatteri.", + "description": "Snabbladdning från hembatteri tills det är urladdat till {limit}.", "label": "Batteri Boost", "mode": "Endast tillgänglig vid 'Sol' och 'Min+Sol' läge.", "once": "Boost är aktiverat för denna laddning." diff --git a/i18n/ta.json b/i18n/ta.json index 3eac9a378..502fbeefb 100644 --- a/i18n/ta.json +++ b/i18n/ta.json @@ -741,7 +741,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Fast மின்னூட்டு இருந்து வீடு battery.", + "description": "வீட்டு மின்கலத்திலிருந்து {limit} வரை இறங்கும் வரை விரைவு மின்னூட்டம்.", "label": "மின்கலம் ஊட்டம்", "mode": "ஞாயிறு மற்றும் குறை+ஞாயிறு பயன்முறையில் மட்டுமே கிடைக்கும்.", "once": "Boost செயலில் க்கு this charging session." diff --git a/i18n/tr.json b/i18n/tr.json index fca4c76a1..2df378899 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -961,7 +961,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Ev bataryasından hızlı doldur.", + "description": "Ev bataryasından {limit} seviyesine düşene kadar hızlı şarj.", "label": "'Batarya Takviyesi”", "mode": "Sadece GES ve Asg.+GES modunda kullanılabilir.", "once": "Bu doldurma oturumu için takviye etkin." diff --git a/i18n/uk.json b/i18n/uk.json index 4ea749dda..9551d1f67 100644 --- a/i18n/uk.json +++ b/i18n/uk.json @@ -965,7 +965,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "Швидка зарядка від домашнього акумулятора.", + "description": "Швидка зарядка від домашнього акумулятора, поки він не розрядиться до {limit}.", "label": "Батарея Boost", "mode": "Доступно лише в режимі сонячної батареї та режимі min+sonal.", "once": "Boost активний для цього сеансу зарядки." diff --git a/i18n/zh-Hans.json b/i18n/zh-Hans.json index 14a023143..fc1255fe0 100644 --- a/i18n/zh-Hans.json +++ b/i18n/zh-Hans.json @@ -697,7 +697,7 @@ }, "loadpointSettings": { "batteryBoost": { - "description": "使用住宅电池快速充电。", + "description": "使用住宅电池快速充电,直到电量降至 {limit}。", "label": "电池快充", "mode": "仅在太阳能和最小+太阳能模式下可用。", "once": "本次充电会话已启用快充。" diff --git a/server/http.go b/server/http.go index 561164f12..3cde272d6 100644 --- a/server/http.go +++ b/server/http.go @@ -215,6 +215,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) { "smartFeedInPriorityDelete": {"DELETE", "/smartfeedinprioritylimit", floatPtrHandler(pass(lp.SetSmartFeedInPriorityLimit), lp.GetSmartFeedInPriorityLimit)}, "priority": {"POST", "/priority/{value:[0-9]+}", intHandler(pass(lp.SetPriority), lp.GetPriority)}, "batteryBoost": {"POST", "/batteryboost/{value:[01truefalse]+}", boolHandler(lp.SetBatteryBoost, func() bool { return lp.GetBatteryBoost() > 0 })}, + "batteryBoostLimit": {"POST", "/batteryboostlimit/{value:[0-9]+}", intHandler(pass(lp.SetBatteryBoostLimit), lp.GetBatteryBoostLimit)}, } for _, r := range routes { diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go index af4441b60..5f18c50f8 100644 --- a/server/http_config_loadpoint_handler.go +++ b/server/http_config_loadpoint_handler.go @@ -42,6 +42,7 @@ func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig { PlanEnergy: planEnergy, PlanTime: planTime, PlanStrategy: lp.GetPlanStrategy(), + BatteryBoostLimit: lp.GetBatteryBoostLimit(), LimitEnergy: lp.GetLimitEnergy(), LimitSoc: lp.GetLimitSoc(), } diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json index ea25e1a99..c8c86876e 100644 --- a/server/mcp/openapi.json +++ b/server/mcp/openapi.json @@ -1089,7 +1089,7 @@ }, "/loadpoints/{id}/batteryboost/{enable}": { "post": { - "description": "Enable or disable 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.", "externalDocs": { "url": "https://docs.evcc.io/en/docs/features/battery#battery-boost" }, @@ -1113,6 +1113,32 @@ ] } }, + "/loadpoints/{id}/batteryboostlimit/{soc}": { + "post": { + "description": "Set the SoC limit for battery boost. Home battery will be used to support charging up to this SoC level. A value of 100 (default) disabled the boost feature in UI and API.", + "externalDocs": { + "url": "https://docs.evcc.io/en/docs/features/battery#battery-boost" + }, + "operationId": "setLoadpointBatteryBoostLimit", + "parameters": [ + { + "$ref": "#/components/parameters/id" + }, + { + "$ref": "#/components/parameters/soc" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/IntegerResult" + } + }, + "summary": "Set battery boost SoC limit", + "tags": [ + "loadpoints" + ] + } + }, "/loadpoints/{id}/disable/delay/{delay}": { "post": { "description": "Delay before charging stops in solar mode.", diff --git a/server/mcp/openapi.md b/server/mcp/openapi.md index e6f43e3c6..4b1ad3d3e 100644 --- a/server/mcp/openapi.md +++ b/server/mcp/openapi.md @@ -482,7 +482,7 @@ call removeLoadpointVehicle { ## setLoadpointBatteryBoost -Enable or disable battery boost. +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. **Tags:** loadpoints @@ -502,6 +502,28 @@ call setLoadpointBatteryBoost { } ``` +## setLoadpointBatteryBoostLimit + +Set the SoC limit for battery boost. Home battery will be used to support charging up to this SoC level. A value of 100 (default) disabled the boost feature in UI and API. + +**Tags:** loadpoints + +**Arguments:** + +| Name | Type | Description | +|------|------|-------------| +| id | integer | Loadpoint index starting at 1 | +| soc | number | SOC in % | + +**Example call:** + +```json +call setLoadpointBatteryBoostLimit { + "id": 123, + "soc": 123.45 +} +``` + ## setLoadpointDisableDelay Delay before charging stops in solar mode. diff --git a/server/mqtt.go b/server/mqtt.go index 855534c2e..451f18d26 100644 --- a/server/mqtt.go +++ b/server/mqtt.go @@ -260,6 +260,7 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint. {"smartCostLimit", floatPtrSetter(pass(lp.SetSmartCostLimit))}, {"smartFeedInPriorityLimit", floatPtrSetter(pass(lp.SetSmartFeedInPriorityLimit))}, {"batteryBoost", boolSetter(lp.SetBatteryBoost)}, + {"batteryBoostLimit", intSetter(pass(lp.SetBatteryBoostLimit))}, {"planStrategy", planStrategySetter(lp.SetPlanStrategy)}, {"planEnergy", planGoalSetter(lp.SetPlanEnergy)}, {"vehicle", func(payload string) error { diff --git a/server/openapi.yaml b/server/openapi.yaml index a6b7859db..0be12e2b0 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -192,7 +192,7 @@ paths: post: operationId: setLoadpointBatteryBoost summary: Set battery boost - description: "Enable or disable 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." externalDocs: url: https://docs.evcc.io/en/docs/features/battery#battery-boost tags: @@ -203,6 +203,21 @@ paths: responses: "200": $ref: "#/components/responses/BooleanResult" + /loadpoints/{id}/batteryboostlimit/{soc}: + post: + operationId: setLoadpointBatteryBoostLimit + summary: Set battery boost SoC limit + description: "Set the SoC limit for battery boost. Home battery will be used to support charging up to this SoC level. A value of 100 (default) disabled the boost feature in UI and API." + externalDocs: + url: https://docs.evcc.io/en/docs/features/battery#battery-boost + tags: + - loadpoints + parameters: + - $ref: "#/components/parameters/id" + - $ref: "#/components/parameters/soc" + responses: + "200": + $ref: "#/components/responses/IntegerResult" /loadpoints/{id}/disable/delay/{delay}: post: operationId: setLoadpointDisableDelay diff --git a/tests/boot.spec.ts b/tests/boot.spec.ts index ec2c380b5..f3b7fa53e 100644 --- a/tests/boot.spec.ts +++ b/tests/boot.spec.ts @@ -15,15 +15,9 @@ test.beforeEach(async ({ page }) => { }); test.describe("boost", async () => { - test("not visible when not experimental", async ({ page }) => { - await expect(page.getByTestId("vehicle-status-batteryboost")).not.toBeVisible(); - await page.getByTestId("loadpoint-settings-button").nth(1).click(); - const modal = page.getByTestId("loadpoint-settings-modal"); - await expect(modal.getByTestId("battery-boost")).not.toBeVisible(); - }); - - test("activate boost in solar mode", async ({ page }) => { - await expect(page.getByTestId("vehicle-status-batteryboost")).not.toBeVisible(); + test("activate and deactivate boost in solar mode", async ({ page }) => { + const boostButton = page.getByTestId("battery-boost-button"); + await expect(boostButton).not.toBeVisible(); await page .getByTestId("mode") .first() @@ -31,22 +25,57 @@ test.describe("boost", async () => { .click(); await page.getByTestId("loadpoint-settings-button").nth(1).click(); const modal = page.getByTestId("loadpoint-settings-modal"); - await modal.getByTestId("battery-boost").getByTestId("battery-boost-checkbox").click(); + await modal.getByTestId("battery-boost-limit").selectOption("20 %"); await expect(modal.getByTestId("battery-boost")).toContainText( - "Boost active for this charging session." + "Allow fast charging from home battery until it's drained to 20%." ); + await page.waitForLoadState("networkidle"); await modal.getByLabel("Close").click(); await expectModalHidden(modal); - await expect(page.getByTestId("vehicle-status-batteryboost")).toBeVisible(); + await expect(boostButton).toHaveAttribute("aria-label", "Battery boost ready"); + // activate boost + await boostButton.click(); + await expect(boostButton).toHaveAttribute("aria-label", "Battery boost active"); + // deactivate boost + await boostButton.click(); + await expect(boostButton).toHaveAttribute("aria-label", "Battery boost ready"); }); - test("disabled in fast mode", async ({ page }) => { - await page.getByTestId("mode").first().getByRole("button", { name: "Fast" }).click(); + test("battery too low for boost when limit above soc", async ({ page }) => { + 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"); - await expect(modal.getByTestId("battery-boost-checkbox")).toBeDisabled(); - await expect(modal.getByTestId("battery-boost")).toContainText( - "Only available in solar and min+solar mode." - ); + await modal.getByTestId("battery-boost-limit").selectOption("90 %"); + await expect(modal.getByTestId("battery-boost")).toContainText("drained to 90%"); + await page.waitForLoadState("networkidle"); + await modal.getByLabel("Close").click(); + await expectModalHidden(modal); + // limit (90%) above battery soc (50%) + await expect(boostButton).toHaveAttribute("aria-label", "Battery too low for boost"); + }); + + test("boost button disabled in fast mode", async ({ page }) => { + const boostButton = page.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 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 expect(boostButton).toBeDisabled(); }); });