Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbb7d704f6 | ||
|
|
d284a2f9af | ||
|
|
96fb302147 | ||
|
|
eb836e9ec8 | ||
|
|
52c0cfaa5d | ||
|
|
d1b1ded0e6 |
14 changed files with 116 additions and 64 deletions
9
.github/workflows/command-backport.yml
vendored
9
.github/workflows/command-backport.yml
vendored
|
|
@ -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 "")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
11
cmd/setup.go
11
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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
2
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
|
||||
|
|
|
|||
4
go.sum
4
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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue