From f2a69620ccc9951510c7d64659abbdcc46bc83c6 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 24 May 2026 10:26:36 +0200 Subject: [PATCH] OCPP: survive mid-setup disconnect on buggy chargers (#30113) (#30165) --- charger/ocpp/cp.go | 22 ++++++++++++++++++++++ charger/ocpp/cp_core_test.go | 26 ++++++++++++++++++++++++++ charger/ocpp/cp_setup.go | 7 +++++++ charger/ocpp/helper.go | 5 +++++ charger/ocpp/helper_test.go | 9 +++++++++ 5 files changed, 69 insertions(+) diff --git a/charger/ocpp/cp.go b/charger/ocpp/cp.go index f200eceae..90f2f2673 100644 --- a/charger/ocpp/cp.go +++ b/charger/ocpp/cp.go @@ -6,6 +6,7 @@ import ( "sync" "time" + "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" "github.com/lorenzodonini/ocpp-go/ocpp1.6/remotetrigger" @@ -212,6 +213,27 @@ func (cp *CP) HasConnected() <-chan struct{} { return cp.connectC } +// waitConnected blocks until the charge point is connected (i.e. a +// BootNotification has been received after the most recent disconnect). +// Returns immediately if already connected, otherwise waits for the next +// BootNotification on the coalescing request channel, bounded by Timeout. +func (cp *CP) waitConnected(ctx context.Context) error { + if cp.Connected() { + return nil + } + + cp.log.DEBUG.Printf("charge point disconnected, waiting for reconnect") + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(Timeout): + return api.ErrTimeout + case <-cp.bootNotificationRequestC: + return nil + } +} + // MonitorReboot ensures the given function runs only once per CP instance. // Used to start the reboot monitor goroutine for multi-connector charge points. func (cp *CP) MonitorReboot(ctx context.Context, setup func() error) { diff --git a/charger/ocpp/cp_core_test.go b/charger/ocpp/cp_core_test.go index b68624ee8..c5c9e5a32 100644 --- a/charger/ocpp/cp_core_test.go +++ b/charger/ocpp/cp_core_test.go @@ -239,3 +239,29 @@ func TestMonitorRebootOnlyOnce(t *testing.T) { require.Eventually(t, func() bool { return callCount.Load() == 1 }, time.Second, 10*time.Millisecond, "setup should be called exactly once") } + +func TestWaitConnectedReturnsImmediatelyWhenConnected(t *testing.T) { + log := util.NewLogger("test") + cp := NewChargePoint(log, "test-cp") + + cp.connect(true) + require.NoError(t, cp.waitConnected(t.Context())) +} + +func TestWaitConnectedResumesOnReconnectBoot(t *testing.T) { + log := util.NewLogger("test") + cp := NewChargePoint(log, "test-cp") + + // not connected, simulates the CP having dropped mid-setup + require.False(t, cp.Connected()) + + // schedule a reconnect's BootNotification after a short delay + go func() { + time.Sleep(20 * time.Millisecond) + _, _ = cp.OnBootNotification(&core.BootNotificationRequest{ + ChargePointModel: "TestModel", + }) + }() + + require.NoError(t, cp.waitConnected(t.Context())) +} diff --git a/charger/ocpp/cp_setup.go b/charger/ocpp/cp_setup.go index b5d4bae8c..b2d246adf 100644 --- a/charger/ocpp/cp_setup.go +++ b/charger/ocpp/cp_setup.go @@ -18,6 +18,13 @@ func (cp *CP) Setup(ctx context.Context, meterValues string, meterInterval time. cp.log.DEBUG.Printf("failed configuring availability: %v", err) } + // Some chargers (e.g. EN+/EVSEDO FW 1.1.805, issue #30113) hang on ChangeAvailability + // and drop the WebSocket. Without this, every subsequent request fails with + // "no client exists" and aborts charger creation fatally. + if err := cp.waitConnected(ctx); err != nil { + return err + } + // auto configuration desiredMeasurands := "Power.Active.Import,Energy.Active.Import.Register,Current.Import,Voltage,Current.Offered,Power.Offered,SoC" diff --git a/charger/ocpp/helper.go b/charger/ocpp/helper.go index 894965fad..13834f042 100644 --- a/charger/ocpp/helper.go +++ b/charger/ocpp/helper.go @@ -31,6 +31,11 @@ func wait(err error, rc chan error) error { if oe, ok := errors.AsType[*ocpp.Error](err); ok && oe.Code == ocppj.GenericError { err = api.ErrTimeout } + } else if strings.Contains(err.Error(), "no client") { + // The ocpp-go dispatcher returns "cannot send request ..., no client ... exists" + // when the CP disconnected before the request could be queued. Treat as transient + // timeout so callers can react to a reconnect instead of aborting setup fatally. + err = api.ErrTimeout } return err } diff --git a/charger/ocpp/helper_test.go b/charger/ocpp/helper_test.go index 6a7acf587..65d274afb 100644 --- a/charger/ocpp/helper_test.go +++ b/charger/ocpp/helper_test.go @@ -1,13 +1,22 @@ package ocpp import ( + "errors" "testing" "time" + "github.com/evcc-io/evcc/api" "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" "github.com/stretchr/testify/assert" ) +func TestWaitMapsNoClientToTimeout(t *testing.T) { + // dispatcher returns this verbatim when the CP disconnected before the request could be queued + err := errors.New("cannot send request 4711, no client ABC123 exists") + got := wait(err, nil) + assert.ErrorIs(t, got, api.ErrTimeout) +} + func TestSortByAge(t *testing.T) { assert.Equal(t, []types.MeterValue{ {Timestamp: nil},