Loadpoint: tolerate api.ErrNotAvailable on phase switch (#29975)

This commit is contained in:
andig 2026-05-17 15:01:43 +02:00 • committed by GitHub
parent e48887e04b
commit 177cb19d5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 118 additions and 5 deletions

View file

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

View file

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

View file

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