diff --git a/charger/ocpp/connector.go b/charger/ocpp/connector.go index 87512b0fe6..fe8eaada40 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 6d4a5ab6b5..55ec07aa2a 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 3d79678efa..b51fbe6624 100644 --- a/charger/ocpp/connector_test.go +++ b/charger/ocpp/connector_test.go @@ -210,6 +210,60 @@ 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" + + // 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: 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() + suite.True(suite.conn.remoteStarted, "guard must be set after first Preparing") + + // repeated Preparing must not re-arm (guard stays set) + preparing() + suite.True(suite.conn.remoteStarted, "guard must stay set on repeated 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() + 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() {