OCPP: clear stale transaction state on Available status (#30247)

This commit is contained in:
premultiply 2026-06-01 15:07:10 +02:00 • committed by GitHub
parent a0a110580d
commit e407006e03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 95 additions and 0 deletions

View file

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

View file

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