From d1b1ded0e62ff92101908f5ee36a95bcee59e679 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sun, 16 Aug 2026 11:24:40 +0200 Subject: [PATCH 1/6] chore: fix release workflow (#32878) --- .github/workflows/command-backport.yml | 9 +++------ .goreleaser.yml | 2 -- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/workflows/command-backport.yml b/.github/workflows/command-backport.yml index 3c6d8139f..138a8836a 100644 --- a/.github/workflows/command-backport.yml +++ b/.github/workflows/command-backport.yml @@ -157,18 +157,15 @@ jobs: TITLE: ${{ steps.pr.outputs.title }} ACTOR: ${{ github.event.comment.user.login }} run: | - # the release line the branch was cut from, 0.313 for release/0.313.1 - line=$(echo "${TARGET#release/}" | cut -d. -f1,2) - # the pull request is authored by the deploy token, so assign the # requester. the body stays empty, squashing it would carry the text # into the release branch as the commit description. - # the marker trails the title so the changelog filters and groups still - # see the original prefix, goreleaser strips it from the release notes + # the title stays untouched so the changelog filters and groups still + # see the original prefix, the backport label identifies the pull request url=$(gh pr create \ --base "$TARGET" \ --head "$BRANCH" \ - --title "$TITLE [$line backport]" \ + --title "$TITLE" \ --label backport \ --assignee "$ACTOR" \ --body "") diff --git a/.goreleaser.yml b/.goreleaser.yml index 56c7630cf..b21d3bff2 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -71,8 +71,6 @@ snapshot: changelog: sort: asc - # drop the marker the backport command appends to the pull request title - format: '{{ .SHA }} {{ reReplaceAll ` \[\d+\.\d+ backport\]` .Message "" }}' filters: exclude: - "^chore" From 52c0cfaa5d33670834af06e94562588fa4924d9d Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 17 Aug 2026 15:25:12 +0200 Subject: [PATCH 2/6] Loadpoint: fix config error for disabled loadpoints (#32923) --- server/http_config_loadpoint_handler.go | 4 +++- server/http_config_loadpoint_handler_test.go | 1 + tests/config-disable.spec.ts | 18 +++++++++++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go index 23f875c48..c30359536 100644 --- a/server/http_config_loadpoint_handler.go +++ b/server/http_config_loadpoint_handler.go @@ -13,6 +13,7 @@ import ( "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/templates" + "github.com/go-viper/mapstructure/v2" "github.com/gorilla/mux" ) @@ -119,8 +120,9 @@ func loadpointConfig(dev config.Device[loadpoint.API]) (loadpointFullConfig, err return loadpointFullConfig{}, err } + // non-strict: runtime settings (mode, ...) are persisted alongside the config var static loadpoint.StaticConfig - if err := util.DecodeOther(staticMap, &static); err != nil { + if err := mapstructure.Decode(staticMap, &static); err != nil { return loadpointFullConfig{}, err } diff --git a/server/http_config_loadpoint_handler_test.go b/server/http_config_loadpoint_handler_test.go index e9ddf4a49..a8050bd62 100644 --- a/server/http_config_loadpoint_handler_test.go +++ b/server/http_config_loadpoint_handler_test.go @@ -25,6 +25,7 @@ func TestLoadpointConfigDisabledNilInstance(t *testing.T) { "charger": "wallbox", "meter": "lp-meter", "title": "Garage", + "mode": "pv", // runtime setting persisted by the settings adapter }, } diff --git a/tests/config-disable.spec.ts b/tests/config-disable.spec.ts index 4d94087ed..744bb07e7 100644 --- a/tests/config-disable.spec.ts +++ b/tests/config-disable.spec.ts @@ -59,6 +59,21 @@ test.describe("disable / enable", async () => { await page.reload(); await createLoadpoint(page, "Garage"); + // restart to bring both loadpoints live + await restart(); + await page.reload(); + + // switch charge mode on dashboard; runtime setting is persisted next to the loadpoint config + await page.goto("/"); + const modeButton = page + .getByTestId("loadpoint") + .nth(0) + .getByTestId("mode") + .getByRole("button", { name: "Fast" }); + await modeButton.click(); + await expect(modeButton).toHaveClass(/active/); + await page.goto("/#/config"); + const target = page.getByTestId("loadpoint").nth(0); await toggleLoadpointDisable(page, 0, "Disable"); @@ -66,10 +81,11 @@ test.describe("disable / enable", async () => { // card shows disabled state await expect(disabledBadge(target)).toBeVisible(); - // restart, no fatal + // restart, no fatal, both loadpoints still listed await restart(); await page.reload(); await expectNoFatal(page); + await expect(page.getByTestId("loadpoint")).toHaveCount(2); await expect(disabledBadge(target)).toBeVisible(); // re-enable by clicking the disabled card From eb836e9ec89621da48222914780abc3be00bf2c0 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 18 Aug 2026 08:50:29 +0200 Subject: [PATCH 3/6] Tesla Powerwall Fleet: fix template not selectable in GUI (#32936) --- templates/definition/meter/tesla-powerwall-fleet.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/templates/definition/meter/tesla-powerwall-fleet.yaml b/templates/definition/meter/tesla-powerwall-fleet.yaml index 92f806fe5..84c577edd 100644 --- a/templates/definition/meter/tesla-powerwall-fleet.yaml +++ b/templates/definition/meter/tesla-powerwall-fleet.yaml @@ -19,6 +19,8 @@ requirements: The [myteslamate.com](https://www.myteslamate.com/tesla-api-application-registration/) guide explains the process and generates a free Access and Refresh Token. With this token pair and your Client ID created in the Tesla Developer Account, evcc can directly communicate with the Tesla Fleet API. params: + - name: usage + choice: ["battery"] - name: host - name: password required: true From 96fb302147beee7ab9043e7277f54299bbf60558 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 18 Aug 2026 09:51:14 +0200 Subject: [PATCH 4/6] chore: add ship-go rollup with mdns re-announcement leak fix (#32941) --- go.mod | 2 ++ go.sum | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index bf53915a1..044665c48 100644 --- a/go.mod +++ b/go.mod @@ -269,3 +269,5 @@ replace github.com/lorenzodonini/ocpp-go => github.com/evcc-io/ocpp-go v0.0.0-20 replace github.com/enbility/spine-go => github.com/andig/spine-go v0.7.1-0.20260804073026-f9a8d8e8fd18 replace github.com/enbility/eebus-go => github.com/andig/eebus-go v0.0.0-20260725155950-e735091ff165 + +replace github.com/enbility/ship-go => github.com/andig/ship-go v0.6.1-0.20260818072438-bade5ec9fbd7 diff --git a/go.sum b/go.sum index 642b08740..8ee8c4fc8 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/andig/gosunspec v0.0.0-20260705113727-6d585e133512 h1:1y8dS4GaBB9WUu/ github.com/andig/gosunspec v0.0.0-20260705113727-6d585e133512/go.mod h1:c6P6szcR+ROkqZruOR4f6qbDKFjZX6OitPpj+yJ/r8k= github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e h1:m/NTP3JWpR7M0ljLxiQU4fzR25jjhe1LDtxLMNcoNJQ= github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e/go.mod h1:4VtYzTm//oUipwvO3yh0g/udTE7pYJM+U/kyAuFDsgM= +github.com/andig/ship-go v0.6.1-0.20260818072438-bade5ec9fbd7 h1:YXXxRvtVSTLATXSvMYBtf8PBNeVRBu7WzxfL4PCqN+8= +github.com/andig/ship-go v0.6.1-0.20260818072438-bade5ec9fbd7/go.mod h1:EvRFx83phCwPQrOuWw2CX5t0c6mcFVzIk6gQEz4Lijg= github.com/andig/spine-go v0.7.1-0.20260804073026-f9a8d8e8fd18 h1:FVKqjwJJLo8vV47jXt1lNN4EVJXCyV3LSTNFt9eeimU= github.com/andig/spine-go v0.7.1-0.20260804073026-f9a8d8e8fd18/go.mod h1:ddWZU5BQGyzRGF20Z7rUKFFzbCp+cfu9mZgYtOMW/fM= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= @@ -140,8 +142,6 @@ github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2I github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/enbility/go-avahi v0.0.0-20240909195612-d5de6b280d7a h1:foChWb8lhzqa6lWDRs6COYMdp649YlUirFP8GqoT0JQ= github.com/enbility/go-avahi v0.0.0-20240909195612-d5de6b280d7a/go.mod h1:H64mhYcAQUGUUnVqMdZQf93kPecH4M79xwH95Lddt3U= -github.com/enbility/ship-go v0.6.1-0.20260720110450-0aa90f64ac76 h1:gDrV6vBG3luAgsyX4sXYShITT41CDKXrtyluLeRmglE= -github.com/enbility/ship-go v0.6.1-0.20260720110450-0aa90f64ac76/go.mod h1:EvRFx83phCwPQrOuWw2CX5t0c6mcFVzIk6gQEz4Lijg= github.com/enbility/zeroconf/v2 v2.0.0-20240920094356-be1cae74fda6 h1:XOYvxKtT1oxT37w/5oEiRLuPbm9FuJPt3fiYhX0h8Po= github.com/enbility/zeroconf/v2 v2.0.0-20240920094356-be1cae74fda6/go.mod h1:BszP9qFV14mPXgyIREbgIdQtWxbAj3OKqvK02HihMoM= github.com/evcc-io/modbus v0.0.0-20250501165638-8b6f1fbdb7ea h1:F6eyC8V8wvc3ranlsR4coAls+OPBkVvxyX0a2VqdlPc= From d284a2f9af8e608772cbafb23fd8637158a28d79 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 18 Aug 2026 11:21:08 +0200 Subject: [PATCH 5/6] Vehicle: don't instantiate disabled vehicles (#32943) --- cmd/setup.go | 11 ++++++++--- core/loadpoint.go | 3 ++- core/loadpoint_test.go | 25 +++++++++++++++++++++++++ core/site_vehicles.go | 13 +++++-------- tests/config-disable.spec.ts | 34 ++++++++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 12 deletions(-) diff --git a/cmd/setup.go b/cmd/setup.go index 6a77f1c24..4cc932170 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -538,9 +538,14 @@ func configureVehicles(static []config.Named, names ...string) error { } } - instance, err := vehicleInstance(cc) - if err != nil { - return fmt.Errorf("cannot create vehicle '%s': %w", cc.Name, err) + // disabled vehicles are not instantiated + var instance api.Vehicle + if !conf.Disable { + var err error + instance, err = vehicleInstance(cc) + if err != nil { + return fmt.Errorf("cannot create vehicle '%s': %w", cc.Name, err) + } } mu.Lock() diff --git a/core/loadpoint.go b/core/loadpoint.go index 154686ac0..13f2e6e0e 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -253,7 +253,8 @@ func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, collec } lp.defaultVehicle = dev.Instance() if lp.defaultVehicle == nil { - return lp, errors.New("missing default vehicle instance") + // disabled vehicle + lp.log.DEBUG.Printf("default vehicle '%s' is disabled", lp.VehicleRef) } } diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index 59f55c5c9..d427b0f1d 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -12,7 +12,9 @@ import ( "github.com/evcc-io/evcc/core/soc" "github.com/evcc-io/evcc/messenger" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/config" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) @@ -882,3 +884,26 @@ func TestBatteryBoostHold(t *testing.T) { // which is what keeps the sitePower priority adjustment applied to the loadpoint assert.NotEqual(t, boostDisabled, lp.GetBatteryBoost(), "hold is active") } + +// default vehicle referencing a disabled vehicle must not fail loadpoint creation +func TestNewLoadpointFromConfigDisabledVehicle(t *testing.T) { + config.Reset() + t.Cleanup(config.Reset) + + ctrl := gomock.NewController(t) + + // disabled vehicle: device registered without instance + var vehicle api.Vehicle + require.NoError(t, config.Vehicles().Add(config.NewStaticDevice(config.Named{Name: "vehicle"}, vehicle))) + require.NoError(t, config.Chargers().Add(config.NewStaticDevice(config.Named{Name: "charger"}, api.Charger(api.NewMockCharger(ctrl))))) + + lp, err := NewLoadpointFromConfig(util.NewLogger("foo"), nil, nil, map[string]any{ + "charger": "charger", + "vehicle": "vehicle", + }) + require.NoError(t, err) + require.Nil(t, lp.defaultVehicle) + + // disabled vehicle is filtered from instances + require.Empty(t, config.Instances(config.Vehicles().Devices())) +} diff --git a/core/site_vehicles.go b/core/site_vehicles.go index aa457808c..cedccb264 100644 --- a/core/site_vehicles.go +++ b/core/site_vehicles.go @@ -104,14 +104,7 @@ type vehicles struct { } func (vv *vehicles) Instances() []api.Vehicle { - devs := config.Vehicles().Devices() - - res := make([]api.Vehicle, 0, len(devs)) - for _, dev := range devs { - res = append(res, dev.Instance()) - } - - return res + return config.Instances(config.Vehicles().Devices()) } func (vv *vehicles) Settings() []vehicle.API { @@ -119,6 +112,10 @@ func (vv *vehicles) Settings() []vehicle.API { res := make([]vehicle.API, 0, len(devs)) for _, dev := range devs { + // skip disabled vehicles + if dev.Instance() == nil { + continue + } res = append(res, vehicle.Adapter(vv.log, dev)) } diff --git a/tests/config-disable.spec.ts b/tests/config-disable.spec.ts index 744bb07e7..5f5b66e19 100644 --- a/tests/config-disable.spec.ts +++ b/tests/config-disable.spec.ts @@ -239,6 +239,10 @@ test.describe("disable / enable", async () => { await start(); await page.goto("/#/config"); + // loadpoint so the vehicle shows up on the dashboard + await createLoadpoint(page, "Carport"); + await page.reload(); + // add user-defined vehicle await page.getByTestId("add-vehicle").click(); const modal = page.getByTestId("vehicle-modal"); @@ -277,6 +281,23 @@ soc: await expectNoFatal(page); await expect(disabledBadge(vehicleCard)).toBeVisible(); + // disabled vehicle is hidden from main ui + await page.goto("/"); + await expect(page.getByTestId("loadpoint")).toBeVisible(); + await expect( + page.getByTestId("change-vehicle").getByRole("option", { name: "blue Honda" }) + ).toHaveCount(0); + + // no vehicles entry in more menu + const moreTab = page.getByTestId("tab-more"); + await moreTab.click(); + await expect(moreTab.getByRole("button", { name: "User Interface" })).toBeVisible(); + await expect(moreTab.getByRole("button", { name: "Vehicles" })).not.toBeVisible(); + // close more menu + await moreTab.click(); + + await page.goto("/#/config"); + // re-enable by clicking the disabled card await disabledBadge(vehicleCard).click(); await expect(disabledBadge(vehicleCard)).toHaveCount(0); @@ -286,6 +307,19 @@ soc: await page.reload(); await expectNoFatal(page); await expect(vehicleCard).toContainText("blue Honda"); + + // vehicle is selectable on the dashboard again + await page.goto("/"); + await expect( + page.getByTestId("change-vehicle").getByRole("option", { name: "blue Honda" }) + ).toHaveCount(1); + + // vehicle is back in the more menu vehicles modal + await moreTab.click(); + await moreTab.getByRole("button", { name: "Vehicles" }).click(); + const settingsModal = page.getByTestId("vehicle-settings-modal"); + await expectModalVisible(settingsModal); + await expect(settingsModal.getByRole("group", { name: "blue Honda" })).toBeVisible(); }); }); From bbb7d704f66718f52c145d6cca2738740fecd1e6 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Wed, 19 Aug 2026 09:37:21 +0200 Subject: [PATCH 6/6] Revert "PV: reserve only min power for higher-priority loadpoints starting up" (#32948) --- core/loadpoint.go | 11 +++-------- core/site.go | 4 +--- core/site_prioritize_test.go | 37 ++++++++---------------------------- 3 files changed, 12 insertions(+), 40 deletions(-) diff --git a/core/loadpoint.go b/core/loadpoint.go index 13f2e6e0e..ee0884870 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -1062,20 +1062,15 @@ func (lp *Loadpoint) charging() bool { func (lp *Loadpoint) PvChargeStarting() bool { lp.RLock() enabled := lp.enabled - pvTimer := lp.pvTimer + pvTimerRunning := !lp.pvTimer.IsZero() lp.RUnlock() if lp.GetMode() != api.ModePV || !lp.connected() || lp.chargeGoalReached(enabled) { return false } - if enabled || pvTimer.IsZero() { - return false - } - - // a timer restarting on every surplus dip never starts the loadpoint, hence - // only claim surplus once it has survived half of the enable delay (#32778) - return lp.clock.Since(pvTimer) >= lp.GetEnableDelay()/2 + // enable timer running (not yet enabled) + return !enabled && pvTimerRunning } // chargeGoalReached reports whether the loadpoint will not draw more: enabled diff --git a/core/site.go b/core/site.go index 6f0191616..901946704 100644 --- a/core/site.go +++ b/core/site.go @@ -1127,9 +1127,7 @@ func (site *Site) reservedPVPower(lp updater) float64 { continue } if other.EffectivePriority() > prio && other.PvChargeStarting() { - // min power is what the loadpoint needs to start, reserving its max - // power would starve this loadpoint beyond that (#32778) - reserved += other.EffectiveMinPower() + reserved += other.EffectiveMaxPower() } } diff --git a/core/site_prioritize_test.go b/core/site_prioritize_test.go index b6d4bcb20..aeaae88b8 100644 --- a/core/site_prioritize_test.go +++ b/core/site_prioritize_test.go @@ -10,10 +10,8 @@ import ( "github.com/evcc-io/evcc/util" ) -const testEnableDelay = time.Minute - func newPVLoadpoint(prio int, mode api.ChargeMode, status api.ChargeStatus, enabled bool, timer time.Time) *Loadpoint { - lp := &Loadpoint{ + return &Loadpoint{ log: util.NewLogger("lp"), clock: clock.NewMock(), minCurrent: minA, @@ -25,21 +23,13 @@ func newPVLoadpoint(prio int, mode api.ChargeMode, status api.ChargeStatus, enab pvTimer: timer, priority: prio, } - lp.Enable.Delay = testEnableDelay - return lp -} - -// pvTimerStarted returns a timer start that has been running for the given duration -func pvTimerStarted(running time.Duration) time.Time { - return clock.NewMock().Now().Add(-running) } func TestPvChargeStarting(t *testing.T) { now := clock.NewMock().Now() - settled := pvTimerStarted(testEnableDelay / 2) // enable timer running but car already full (soc at default 100% limit): not starting up - enablePendingFull := newPVLoadpoint(0, api.ModePV, api.StatusB, false, settled) + enablePendingFull := newPVLoadpoint(0, api.ModePV, api.StatusB, false, now) enablePendingFull.vehicleSoc = 100 tc := []struct { @@ -47,13 +37,12 @@ func TestPvChargeStarting(t *testing.T) { lp *Loadpoint starting bool }{ - {"enable timer running", newPVLoadpoint(0, api.ModePV, api.StatusB, false, settled), true}, - {"enable timer just restarted", newPVLoadpoint(0, api.ModePV, api.StatusB, false, now), false}, + {"enable timer running", newPVLoadpoint(0, api.ModePV, api.StatusB, false, now), true}, {"enabled not charging", newPVLoadpoint(0, api.ModePV, api.StatusB, true, time.Time{}), false}, {"enabled and charging", newPVLoadpoint(0, api.ModePV, api.StatusC, true, time.Time{}), false}, {"disabled idle", newPVLoadpoint(0, api.ModePV, api.StatusB, false, time.Time{}), false}, - {"disconnected", newPVLoadpoint(0, api.ModePV, api.StatusA, false, settled), false}, - {"not pv mode", newPVLoadpoint(0, api.ModeNow, api.StatusB, false, settled), false}, + {"disconnected", newPVLoadpoint(0, api.ModePV, api.StatusA, false, now), false}, + {"not pv mode", newPVLoadpoint(0, api.ModeNow, api.StatusB, false, now), false}, {"enable pending but car full", enablePendingFull, false}, } @@ -68,7 +57,7 @@ func TestReservedPVPower(t *testing.T) { Voltage = 230 // higher-priority loadpoint (prio 1) starting up - high := newPVLoadpoint(1, api.ModePV, api.StatusB, false, pvTimerStarted(testEnableDelay/2)) + high := newPVLoadpoint(1, api.ModePV, api.StatusB, false, clock.NewMock().Now()) // lower-priority loadpoint (prio 0) in PV mode low := newPVLoadpoint(0, api.ModePV, api.StatusB, false, time.Time{}) @@ -77,20 +66,10 @@ func TestReservedPVPower(t *testing.T) { loadpoints: []*Loadpoint{high, low}, } - // low reserves the power high needs to start, not its max power (#32778) - if got, want := site.reservedPVPower(low), high.EffectiveMinPower(); got != want { + // low reserves high's anticipated max power while high is starting up + if got, want := site.reservedPVPower(low), high.EffectiveMaxPower(); got != want { t.Errorf("low: want %.0f, got %.0f", want, got) } - if high.EffectiveMinPower() == high.EffectiveMaxPower() { - t.Fatal("test requires min and max power to differ") - } - - // a timer restarting on every surplus dip must not reserve at all (#32778) - high.pvTimer = clock.NewMock().Now() - if got := site.reservedPVPower(low); got != 0 { - t.Errorf("low while high timer restarts: want 0, got %.0f", got) - } - high.pvTimer = pvTimerStarted(testEnableDelay / 2) // high (top priority) reserves nothing if got := site.reservedPVPower(high); got != 0 {