From e407006e03ff082a3d6547a7d11b23f81ef2b6c8 Mon Sep 17 00:00:00 2001 From: premultiply <4681172+premultiply@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:07:10 +0200 Subject: [PATCH] OCPP: clear stale transaction state on Available status (#30247) --- charger/ocpp/connector_core.go | 11 +++++ charger/ocpp/connector_test.go | 84 ++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/charger/ocpp/connector_core.go b/charger/ocpp/connector_core.go index a780357e6..15d45e7f2 100644 --- a/charger/ocpp/connector_core.go +++ b/charger/ocpp/connector_core.go @@ -28,15 +28,26 @@ func (conn *Connector) OnStatusNotification(request *core.StatusNotificationRequ conn.mu.Lock() defer conn.mu.Unlock() + var applied bool if conn.status == nil { conn.status = request close(conn.statusC) // signal initial status received + applied = true } else if request.Timestamp == nil || conn.timestampValid(request.Timestamp.Time) { conn.status = request + applied = true } else { conn.log.TRACE.Printf("ignoring status: %s < %s", request.Timestamp.Time, conn.status.Timestamp) } + // Available means cable unplugged and any prior transaction is stale + if applied && request.Status == core.ChargePointStatusAvailable && conn.txnId != 0 { + conn.log.DEBUG.Printf("clearing stale transaction %d on Available status", conn.txnId) + conn.txnId = 0 + conn.idTag = "" + conn.assumeMeterStopped() + } + if conn.isWaitingForAuth() { if conn.remoteIdTag != "" { // dispatch asynchronously: RemoteStartTransactionRequest issues a diff --git a/charger/ocpp/connector_test.go b/charger/ocpp/connector_test.go index 459c612ce..83f7fa922 100644 --- a/charger/ocpp/connector_test.go +++ b/charger/ocpp/connector_test.go @@ -7,6 +7,7 @@ import ( "github.com/benbjohnson/clock" "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/types" "github.com/stretchr/testify/suite" ) @@ -152,6 +153,89 @@ func (suite *connTestSuite) TestConnectorMeasurementsRunningTxn() { suite.NoError(err, "Voltages") } +// TestOnStatusNotificationClearsStaleTxn ensures that a transaction left over +// from a previous session (e.g. because the charger never sent StopTransaction, +// like the Zaptec Go 2 in local OCPP mode) is cleared when the connector +// returns to Available, so the next Preparing can trigger RemoteStartTransaction. +func (suite *connTestSuite) TestOnStatusNotificationClearsStaleTxn() { + suite.conn.remoteIdTag = "evcc" + suite.conn.txnId = 42 + suite.conn.idTag = "stale" + + _, err := suite.conn.OnStatusNotification(&core.StatusNotificationRequest{ + ConnectorId: 1, + Status: core.ChargePointStatusAvailable, + ErrorCode: core.NoError, + Timestamp: types.NewDateTime(suite.clock.Now()), + }) + suite.NoError(err) + suite.Equal(0, suite.conn.txnId, "txnId should be cleared on Available") + suite.Equal("", suite.conn.idTag, "idTag should be cleared on Available") + + // next Preparing notification must now satisfy NeedsAuthentication + _, err = suite.conn.OnStatusNotification(&core.StatusNotificationRequest{ + ConnectorId: 1, + Status: core.ChargePointStatusPreparing, + ErrorCode: core.NoError, + Timestamp: types.NewDateTime(suite.clock.Now().Add(time.Second)), + }) + suite.NoError(err) + suite.True(suite.conn.NeedsAuthentication(), "Preparing after Available should require authentication") +} + +// TestOnStatusNotificationKeepsActiveTxn ensures that an active transaction is +// not cleared by transient status notifications other than Available. +func (suite *connTestSuite) TestOnStatusNotificationKeepsActiveTxn() { + suite.conn.txnId = 42 + suite.conn.idTag = "active" + + for _, status := range []core.ChargePointStatus{ + core.ChargePointStatusCharging, + core.ChargePointStatusSuspendedEV, + core.ChargePointStatusSuspendedEVSE, + core.ChargePointStatusFinishing, + } { + _, err := suite.conn.OnStatusNotification(&core.StatusNotificationRequest{ + ConnectorId: 1, + Status: status, + ErrorCode: core.NoError, + Timestamp: types.NewDateTime(suite.clock.Now()), + }) + suite.NoError(err) + suite.Equalf(42, suite.conn.txnId, "txnId must survive %s", status) + suite.Equalf("active", suite.conn.idTag, "idTag must survive %s", status) + suite.clock.Add(time.Second) + } +} + +// TestOnStatusNotificationKeepsTxnOnIgnoredAvailable ensures we do not clear +// transaction state when an Available notification is rejected due to an +// outdated timestamp (i.e. the cached status remains the current one). +func (suite *connTestSuite) TestOnStatusNotificationKeepsTxnOnIgnoredAvailable() { + // prime with a recent Charging status + _, err := suite.conn.OnStatusNotification(&core.StatusNotificationRequest{ + ConnectorId: 1, + Status: core.ChargePointStatusCharging, + ErrorCode: core.NoError, + Timestamp: types.NewDateTime(suite.clock.Now()), + }) + suite.NoError(err) + suite.conn.txnId = 42 + suite.conn.idTag = "active" + + // out-of-order Available with an older timestamp must be ignored + // and must not clear the running transaction + _, err = suite.conn.OnStatusNotification(&core.StatusNotificationRequest{ + ConnectorId: 1, + Status: core.ChargePointStatusAvailable, + ErrorCode: core.NoError, + Timestamp: types.NewDateTime(suite.clock.Now().Add(-time.Minute)), + }) + suite.NoError(err) + suite.Equal(42, suite.conn.txnId, "txnId must survive ignored Available") + suite.Equal("active", suite.conn.idTag, "idTag must survive ignored Available") +} + func (suite *connTestSuite) TestOnStopTransactionResetsReportedPower() { suite.conn.meterUpdated = suite.clock.Now()