Compare commits

...

6 commits

Author SHA1 Message Date
Michael Geers
bbb7d704f6
Revert "PV: reserve only min power for higher-priority loadpoints starting up" (#32948)
Some checks failed
Release / Validate Tag (push) Has been cancelled
Release / call-build-workflow (push) Has been cancelled
Release / Publish Docker :release (push) Has been cancelled
Release / Github & APT (push) Has been cancelled
Release / Demo (push) Has been cancelled
Release / Hassio Addon (push) Has been cancelled
Release / Delete Release Branch (push) Has been cancelled
2026-08-19 09:37:21 +02:00
Michael Geers
d284a2f9af
Vehicle: don't instantiate disabled vehicles (#32943) 2026-08-18 11:21:08 +02:00
Michael Geers
96fb302147
chore: add ship-go rollup with mdns re-announcement leak fix (#32941) 2026-08-18 09:51:14 +02:00
Michael Geers
eb836e9ec8
Tesla Powerwall Fleet: fix template not selectable in GUI (#32936) 2026-08-18 08:50:29 +02:00
Michael Geers
52c0cfaa5d
Loadpoint: fix config error for disabled loadpoints (#32923) 2026-08-17 15:25:12 +02:00
Michael Geers
d1b1ded0e6
chore: fix release workflow (#32878)
Some checks failed
Release / Validate Tag (push) Has been cancelled
Release / call-build-workflow (push) Has been cancelled
Release / Publish Docker :release (push) Has been cancelled
Release / Github & APT (push) Has been cancelled
Release / Demo (push) Has been cancelled
Release / Hassio Addon (push) Has been cancelled
Release / Delete Release Branch (push) Has been cancelled
2026-08-16 11:24:40 +02:00
14 changed files with 116 additions and 64 deletions

View file

@ -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 "")

View file

@ -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"

View file

@ -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()

View file

@ -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)
}
}
@ -1061,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

View file

@ -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()))
}

View file

@ -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()
}
}

View file

@ -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 {

View file

@ -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))
}

2
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

View file

@ -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
}

View file

@ -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
},
}

View file

@ -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

View file

@ -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
@ -223,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");
@ -261,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);
@ -270,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();
});
});