OCPP: survive mid-setup disconnect on buggy chargers (#30113) (#30165)

This commit is contained in:
andig 2026-05-24 10:26:36 +02:00 • committed by GitHub
parent 0b20540f74
commit f2a69620cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 69 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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