From df49bf6fff96102f0de64c98aef51d3f33818405 Mon Sep 17 00:00:00 2001 From: premultiply <4681172+premultiply@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:45:33 +0200 Subject: [PATCH 1/2] guard RemoteStartTransaction --- charger/ocpp/connector.go | 3 ++- charger/ocpp/connector_core.go | 21 ++++++++++++------ charger/ocpp/connector_test.go | 39 ++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/charger/ocpp/connector.go b/charger/ocpp/connector.go index fa224327d91..e7b588e3851 100644 --- a/charger/ocpp/connector.go +++ b/charger/ocpp/connector.go @@ -31,7 +31,8 @@ type Connector struct { txnId int idTag string - remoteIdTag string + remoteIdTag string + remoteStarted bool // guards RemoteStartTransaction to once per auth cycle meterInterval time.Duration } diff --git a/charger/ocpp/connector_core.go b/charger/ocpp/connector_core.go index 15d45e7f232..e0efb5b0428 100644 --- a/charger/ocpp/connector_core.go +++ b/charger/ocpp/connector_core.go @@ -48,19 +48,26 @@ func (conn *Connector) OnStatusNotification(request *core.StatusNotificationRequ conn.assumeMeterStopped() } + // leaving Preparing status ends the current auth cycle + if applied && request.Status != core.ChargePointStatusPreparing { + conn.remoteStarted = false + } + if conn.isWaitingForAuth() { - if conn.remoteIdTag != "" { - // dispatch asynchronously: RemoteStartTransactionRequest issues a - // synchronous CS→CP request whose response is read by this same - // goroutine, so a blocking call would deadlock the WebSocket read - // loop (cf. ocpp_test_handler.go). + switch { + case conn.remoteIdTag == "": + conn.log.DEBUG.Printf("waiting for local authentication") + + case !conn.remoteStarted: + // guard against re-firing on repeated Preparing notifications + conn.remoteStarted = true + + // dispatch asynchronously go func(idTag string) { if err := conn.RemoteStartTransactionRequest(idTag); err != nil { conn.log.ERROR.Printf("RemoteStartTransaction: %v", err) } }(conn.remoteIdTag) - } else { - conn.log.DEBUG.Printf("waiting for local authentication") } } diff --git a/charger/ocpp/connector_test.go b/charger/ocpp/connector_test.go index 139ebcd1a2d..adbe79004e1 100644 --- a/charger/ocpp/connector_test.go +++ b/charger/ocpp/connector_test.go @@ -210,6 +210,45 @@ func (suite *connTestSuite) TestOnStatusNotificationClearsStaleTxn() { suite.True(suite.conn.NeedsAuthentication(), "Preparing after Available should require authentication") } +// TestOnStatusNotificationRemoteStartGuard ensures RemoteStartTransaction is +// issued at most once per auth cycle: repeated Preparing notifications must not +// re-fire it, while a fresh cycle (leaving and re-entering Preparing) re-arms it. +func (suite *connTestSuite) TestOnStatusNotificationRemoteStartGuard() { + suite.conn.remoteIdTag = "evcc" + + preparing := func(offset time.Duration) { + _, err := suite.conn.OnStatusNotification(&core.StatusNotificationRequest{ + ConnectorId: 1, + Status: core.ChargePointStatusPreparing, + ErrorCode: core.NoError, + Timestamp: types.NewDateTime(suite.clock.Now().Add(offset)), + }) + suite.NoError(err) + } + + // first Preparing arms the guard + preparing(0) + suite.True(suite.conn.remoteStarted, "guard must be set after first Preparing") + + // repeated Preparing must not re-arm (guard stays set) + preparing(time.Second) + suite.True(suite.conn.remoteStarted, "guard must stay set on repeated Preparing") + + // leaving Preparing (e.g. cable unplugged) ends the auth cycle + _, err := suite.conn.OnStatusNotification(&core.StatusNotificationRequest{ + ConnectorId: 1, + Status: core.ChargePointStatusAvailable, + ErrorCode: core.NoError, + Timestamp: types.NewDateTime(suite.clock.Now().Add(2 * time.Second)), + }) + suite.NoError(err) + suite.False(suite.conn.remoteStarted, "guard must reset when leaving Preparing") + + // next Preparing re-arms a fresh RemoteStartTransaction + preparing(3 * time.Second) + suite.True(suite.conn.remoteStarted, "guard must re-arm on the next auth cycle") +} + // TestOnStatusNotificationKeepsActiveTxn ensures that an active transaction is // not cleared by transient status notifications other than Available. func (suite *connTestSuite) TestOnStatusNotificationKeepsActiveTxn() { From d6077d4d23352a224e9347bf273815ccdaee74aa Mon Sep 17 00:00:00 2001 From: premultiply <4681172+premultiply@users.noreply.github.com> Date: Thu, 2 Jul 2026 07:37:54 +0200 Subject: [PATCH 2/2] cover all preparing exit states in test --- charger/ocpp/connector_test.go | 45 ++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/charger/ocpp/connector_test.go b/charger/ocpp/connector_test.go index adbe79004e1..1d190f4cb26 100644 --- a/charger/ocpp/connector_test.go +++ b/charger/ocpp/connector_test.go @@ -216,36 +216,51 @@ func (suite *connTestSuite) TestOnStatusNotificationClearsStaleTxn() { func (suite *connTestSuite) TestOnStatusNotificationRemoteStartGuard() { suite.conn.remoteIdTag = "evcc" - preparing := func(offset time.Duration) { + // notify sends a status with a strictly increasing timestamp so none is + // rejected as stale by timestampValid + var offset time.Duration + notify := func(status core.ChargePointStatus, code core.ChargePointErrorCode) { + offset += time.Second _, err := suite.conn.OnStatusNotification(&core.StatusNotificationRequest{ ConnectorId: 1, - Status: core.ChargePointStatusPreparing, - ErrorCode: core.NoError, + Status: status, + ErrorCode: code, Timestamp: types.NewDateTime(suite.clock.Now().Add(offset)), }) suite.NoError(err) } + preparing := func() { notify(core.ChargePointStatusPreparing, core.NoError) } // first Preparing arms the guard - preparing(0) + preparing() suite.True(suite.conn.remoteStarted, "guard must be set after first Preparing") // repeated Preparing must not re-arm (guard stays set) - preparing(time.Second) + preparing() suite.True(suite.conn.remoteStarted, "guard must stay set on repeated Preparing") - // leaving Preparing (e.g. cable unplugged) ends the auth cycle - _, err := suite.conn.OnStatusNotification(&core.StatusNotificationRequest{ - ConnectorId: 1, - Status: core.ChargePointStatusAvailable, - ErrorCode: core.NoError, - Timestamp: types.NewDateTime(suite.clock.Now().Add(2 * time.Second)), - }) - suite.NoError(err) - suite.False(suite.conn.remoteStarted, "guard must reset when leaving Preparing") + // leaving Preparing for any other status ends the auth cycle and resets the guard + for _, tc := range []struct { + name string + status core.ChargePointStatus + code core.ChargePointErrorCode + }{ + {"Available", core.ChargePointStatusAvailable, core.NoError}, + {"Charging", core.ChargePointStatusCharging, core.NoError}, + {"SuspendedEV", core.ChargePointStatusSuspendedEV, core.NoError}, + {"SuspendedEVSE", core.ChargePointStatusSuspendedEVSE, core.NoError}, + {"Faulted", core.ChargePointStatusFaulted, core.InternalError}, + } { + // re-arm from Preparing + preparing() + suite.True(suite.conn.remoteStarted, "guard must be set before transitioning to %s", tc.name) + + notify(tc.status, tc.code) + suite.False(suite.conn.remoteStarted, "guard must reset when leaving Preparing to %s", tc.name) + } // next Preparing re-arms a fresh RemoteStartTransaction - preparing(3 * time.Second) + preparing() suite.True(suite.conn.remoteStarted, "guard must re-arm on the next auth cycle") }