From 177cb19d5d2e3e53fc06d3e8cc5640c72ddde2a1 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 17 May 2026 15:01:43 +0200 Subject: [PATCH] Loadpoint: tolerate api.ErrNotAvailable on phase switch (#29975) --- charger/ghosteebus.go | 5 +- core/loadpoint.go | 28 +++++++++-- core/loadpoint_phases_test.go | 90 +++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 5 deletions(-) diff --git a/charger/ghosteebus.go b/charger/ghosteebus.go index 9582e1a5f..4fa9fe6d8 100644 --- a/charger/ghosteebus.go +++ b/charger/ghosteebus.go @@ -198,7 +198,10 @@ func (wb *GhostEEBus) getPhases() (int, error) { if res.LimitationReason != "" { msg += fmt.Sprintf(" (%s)", res.LimitationReason) } - return 0, errors.New(msg) + // the phase state cannot be read - signal api.ErrNotAvailable so + // callers treat it as a benign "not available" rather than an error + wb.log.TRACE.Println(msg) + return 0, api.ErrNotAvailable } switch res.CurrentState { diff --git a/core/loadpoint.go b/core/loadpoint.go index f56f1bcd8..17de6ba7e 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -1307,7 +1307,9 @@ func (lp *Loadpoint) fastCharging() error { } } - if err := lp.scalePhasesIfAvailable(phases); err != nil { + // ignore api.ErrNotAvailable: the phase switch could not be performed + // right now, continue with the current phase configuration + if err := lp.scalePhasesIfAvailable(phases); err != nil && !errors.Is(err, api.ErrNotAvailable) { return err } } @@ -1353,7 +1355,13 @@ func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64) in if elapsed := lp.clock.Since(lp.phaseTimer); elapsed >= lp.GetDisableDelay() { if err := lp.scalePhases(1); err != nil { - lp.log.ERROR.Println(err) + // a charger may report it cannot switch phases right now + // (api.ErrNotAvailable); assume a failed switch and stay silent + if !errors.Is(err, api.ErrNotAvailable) { + lp.log.ERROR.Println(err) + } + // switch did not complete - phase count is unchanged + return phases } return 1 } @@ -1382,7 +1390,13 @@ func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64) in if elapsed := lp.clock.Since(lp.phaseTimer); elapsed >= lp.GetEnableDelay() { if err := lp.scalePhases(3); err != nil { - lp.log.ERROR.Println(err) + // a charger may report it cannot switch phases right now + // (api.ErrNotAvailable); assume a failed switch and stay silent + if !errors.Is(err, api.ErrNotAvailable) { + lp.log.ERROR.Println(err) + } + // switch did not complete - phase count is unchanged + return phases } return 3 } @@ -2026,7 +2040,13 @@ func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, consumption, f err = lp.setLimit(0) case lp.scalePhasesRequired(): - err = lp.scalePhases(lp.phasesConfigured) + if err = lp.scalePhases(lp.phasesConfigured); errors.Is(err, api.ErrNotAvailable) { + // the charger cannot switch phases right now (e.g. EEBus charger + // with an ISO 15118 vehicle). Adopt the configured phase count so + // the switch is not re-attempted on every cycle (issue #29974). + lp.SetPhases(lp.phasesConfigured) + err = nil + } case mode == api.ModeOff: var current float64 diff --git a/core/loadpoint_phases_test.go b/core/loadpoint_phases_test.go index d7e720cba..9d82bc866 100644 --- a/core/loadpoint_phases_test.go +++ b/core/loadpoint_phases_test.go @@ -1,6 +1,7 @@ package core import ( + "errors" "strings" "testing" "time" @@ -480,6 +481,40 @@ func TestScalePhasesIfAvailable(t *testing.T) { } } +// TestScalePhasesNotAvailable verifies that a charger reporting api.ErrNotAvailable +// from Phases1p3p (e.g. an EEBus charger with an ISO 15118 vehicle, see issue #29974) +// is treated as a failed switch: the error is api.ErrNotAvailable so callers can +// suppress it, and the phase count is left unchanged. +func TestScalePhasesNotAvailable(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + plainCharger := api.NewMockCharger(ctrl) + phaseCharger := api.NewMockPhaseSwitcher(ctrl) + phaseCharger.EXPECT().Phases1p3p(3).Return(api.ErrNotAvailable) + + lp := &Loadpoint{ + log: util.NewLogger("foo"), + clock: clock.NewMock(), + wakeUpTimer: NewTimer(), + charger: struct { + *api.MockCharger + *api.MockPhaseSwitcher + }{ + plainCharger, + phaseCharger, + }, + phases: 1, // current phase status, switch to 3p will be attempted + } + + err := lp.scalePhases(3) + require.Error(t, err) + require.True(t, errors.Is(err, api.ErrNotAvailable), "want api.ErrNotAvailable, got %v", err) + + // switch did not complete - phase count unchanged + require.Equal(t, 1, lp.GetPhases()) +} + func TestFastChargingCircuitBasedPhaseScaling(t *testing.T) { Voltage = 230 @@ -550,3 +585,58 @@ func TestFastChargingCircuitBasedPhaseScaling(t *testing.T) { }) } } + +// TestUpdatePhaseSwitchNotAvailable verifies that, with a fixed phasesConfigured +// and a charger that refuses phase switching (api.ErrNotAvailable, e.g. an EEBus +// charger with an ISO 15118 vehicle), the configured phase count is adopted so +// the switch is not re-attempted on every update cycle (issue #29974). +func TestUpdatePhaseSwitchNotAvailable(t *testing.T) { + clock := clock.NewMock() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + plainCharger := api.NewMockCharger(ctrl) + phaseCharger := api.NewMockPhaseSwitcher(ctrl) + + plainCharger.EXPECT().Status().Return(api.StatusC, nil).AnyTimes() + plainCharger.EXPECT().Enabled().Return(true, nil).AnyTimes() + plainCharger.EXPECT().Enable(gomock.Any()).Return(nil).AnyTimes() + plainCharger.EXPECT().MaxCurrent(gomock.Any()).Return(nil).AnyTimes() + + // charger cannot switch phases - and must only be asked once + phaseCharger.EXPECT().Phases1p3p(3).Return(api.ErrNotAvailable).Times(1) + + lp := &Loadpoint{ + log: util.NewLogger("foo"), + bus: evbus.New(), + clock: clock, + chargeMeter: &Null{}, + chargeRater: &Null{}, + chargeTimer: &Null{}, + progress: NewProgress(0, 10), + wakeUpTimer: NewTimer(), + mode: api.ModeNow, + minCurrent: minA, + maxCurrent: maxA, + status: api.StatusC, + charger: struct { + *api.MockCharger + *api.MockPhaseSwitcher + }{ + plainCharger, + phaseCharger, + }, + phasesConfigured: 3, // fixed 3p + phases: 0, // unknown + } + + attachListeners(t, lp) + + // first cycle attempts the switch, gets api.ErrNotAvailable, adopts 3p + lp.Update(0, 0, nil, nil, false, false, 0, nil, nil) + require.Equal(t, 3, lp.GetPhases(), "configured phases should be adopted") + + // second cycle must not attempt the switch again (Phases1p3p .Times(1)) + lp.Update(0, 0, nil, nil, false, false, 0, nil, nil) + require.Equal(t, 3, lp.GetPhases()) +}