From 710db15c07aeed99831aff3663176eba177fc12d Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Thu, 19 Mar 2026 12:17:44 +0100 Subject: [PATCH] =?utf8?q?fix(ocpp2):=20audit=20TransactionEvent=20?= =?utf8?q?=E2=80=94=20state=20ownership,=20deauthorization,=20meter=20valu?= =?utf8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit - Fix periodic TransactionEvent(Updated) to include meter values via buildMeterValue - Replace non-UUID temp transactionId with generateUUID in OCPP20AuthAdapter - Refactor state ownership: response handler is sole authority for transactionStarted, StatusNotification(Occupied), and TxUpdatedInterval start - Add transactionPending flag to prevent duplicate RequestStartTransaction race conditions - Add requestDeauthorizeTransaction per E05.FR.09/FR.10/E06.FR.04: sends Updated(Deauthorized, SuspendedEVSE) then Ended(Deauthorized, DeAuthorized) - Fix rejection check to cover all non-Accepted idTokenInfo statuses - Extract buildFinalMeterValues helper to eliminate DRY violation - Skip Occupied/TxUpdatedInterval setup when idToken is rejected in same response - Remove cleanup from Ended response handler (owned by caller) - Add tests for getTxUpdatedInterval, requestDeauthorizeTransaction, Updated-failure path --- src/charging-station/ChargingStation.ts | 5 +- src/charging-station/Helpers.ts | 1 + .../ocpp/2.0/OCPP20IncomingRequestService.ts | 28 +- .../ocpp/2.0/OCPP20ResponseService.ts | 95 +++---- .../ocpp/2.0/OCPP20ServiceUtils.ts | 99 +++++-- .../ocpp/auth/adapters/OCPP20AuthAdapter.ts | 5 +- src/types/ConnectorStatus.ts | 6 + ...IncomingRequestService-GroupIdStop.test.ts | 2 +- ...estService-RequestStartTransaction.test.ts | 2 +- ...20ResponseService-TransactionEvent.test.ts | 84 +++--- ...CPP20ServiceUtils-TransactionEvent.test.ts | 242 ++++++++++++++++++ 11 files changed, 433 insertions(+), 136 deletions(-) diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index 05040822..7fd59b9a 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -43,6 +43,7 @@ import { MeterValueMeasurand, type MeterValuesRequest, type MeterValuesResponse, + type OCPP20MeterValue, OCPPVersion, type OutgoingRequest, PowerUnits, @@ -1119,12 +1120,14 @@ export class ChargingStation extends EventEmitter { connector.transactionTxUpdatedSetInterval = setInterval(() => { const connectorStatus = this.getConnectorStatus(connectorId) if (connectorStatus?.transactionStarted === true && connectorStatus.transactionId != null) { + const meterValue = buildMeterValue(this, connectorId, 0, interval) as OCPP20MeterValue OCPP20ServiceUtils.sendTransactionEvent( this, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.MeterValuePeriodic, connectorId, - connectorStatus.transactionId as string + connectorStatus.transactionId as string, + { meterValue: [meterValue] } ).catch((error: unknown) => { logger.error( `${this.logPrefix()} Error sending periodic TransactionEvent at TxUpdatedInterval:`, diff --git a/src/charging-station/Helpers.ts b/src/charging-station/Helpers.ts index dc5ec1da..85bf9322 100644 --- a/src/charging-station/Helpers.ts +++ b/src/charging-station/Helpers.ts @@ -556,6 +556,7 @@ export const resetConnectorStatus = (connectorStatus: ConnectorStatus | undefine ) } resetAuthorizeConnectorStatus(connectorStatus) + connectorStatus.transactionPending = false connectorStatus.transactionRemoteStarted = false connectorStatus.transactionStarted = false delete connectorStatus.transactionStart diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index 2604396b..99d3c41a 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -411,8 +411,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { error ) }) - const txUpdatedInterval = OCPP20ServiceUtils.getTxUpdatedInterval(chargingStation) - chargingStation.startTxUpdatedInterval(connectorId, txUpdatedInterval) } } } @@ -2276,14 +2274,17 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { ) } - if (connectorStatus.transactionStarted === true) { + if ( + connectorStatus.transactionStarted === true || + connectorStatus.transactionPending === true + ) { logger.warn( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Connector ${connectorId.toString()} already has an active transaction` + `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Connector ${connectorId.toString()} already has an active or pending transaction` ) return { status: RequestStartStopStatusEnumType.Rejected, statusInfo: { - additionalInfo: `Connector ${connectorId.toString()} already has an active transaction`, + additionalInfo: `Connector ${connectorId.toString()} already has an active or pending transaction`, reasonCode: ReasonCodeEnumType.TxInProgress, }, transactionId: generateUUID(), @@ -2474,7 +2475,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { logger.debug( `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Setting transaction state for connector ${connectorId.toString()}, transaction ID: ${transactionId}` ) - connectorStatus.transactionStarted = true + connectorStatus.transactionPending = true connectorStatus.transactionId = transactionId connectorStatus.transactionIdTag = idToken.idToken connectorStatus.transactionGroupIdToken = groupIdToken?.idToken @@ -2485,16 +2486,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Transaction state set successfully for connector ${connectorId.toString()}` ) - logger.debug( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Updating connector ${connectorId.toString()} status to Occupied` - ) - await sendAndSetConnectorStatus( - chargingStation, - connectorId, - ConnectorStatusEnum.Occupied, - evseId - ) - if (chargingProfile != null) { connectorStatus.chargingProfiles ??= [] connectorStatus.chargingProfiles.push(chargingProfile) @@ -2866,7 +2857,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { presentedGroupIdToken?: OCPP20IdTokenType ): boolean { const connectorStatus = chargingStation.getConnectorStatus(connectorId) - if (connectorStatus?.transactionStarted !== true) { + if ( + connectorStatus?.transactionStarted !== true && + connectorStatus?.transactionPending !== true + ) { logger.debug( `${chargingStation.logPrefix()} ${moduleName}.isAuthorizedToStopTransaction: No active transaction on connector ${connectorId.toString()}` ) diff --git a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts index 8a49ec10..6b6bcf2f 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts @@ -2,11 +2,7 @@ import type { ValidateFunction } from 'ajv' import type { OCPP20IncomingRequestCommand } from '../../../types/index.js' -import { - addConfigurationKey, - type ChargingStation, - resetConnectorStatus, -} from '../../../charging-station/index.js' +import { addConfigurationKey, type ChargingStation } from '../../../charging-station/index.js' import { ChargingStationEvents, ConnectorStatusEnum, @@ -310,49 +306,26 @@ export class OCPP20ResponseService extends OCPPResponseService { switch (requestPayload.eventType) { case OCPP20TransactionEventEnumType.Ended: + // Cleanup (stopTxUpdatedInterval, resetConnectorStatus, StatusNotification) is owned by + // the caller that sends TransactionEvent(Ended) — see requestStopTransaction in OCPP20ServiceUtils. if (connectorId != null) { - if ( - !chargingStation.isChargingStationAvailable() || - !chargingStation.isConnectorAvailable(connectorId) - ) { - sendAndSetConnectorStatus( - chargingStation, - connectorId, - ConnectorStatusEnum.Unavailable - ).catch((error: unknown) => { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Error sending StatusNotification(Unavailable):`, - error - ) - }) - } else { - sendAndSetConnectorStatus( - chargingStation, - connectorId, - ConnectorStatusEnum.Available - ).catch((error: unknown) => { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Error sending StatusNotification(Available):`, - error - ) - }) - } - chargingStation.stopTxUpdatedInterval(connectorId) - chargingStation.stopMeterValues(connectorId) logger.info( `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Transaction ${requestPayload.transactionInfo.transactionId} ENDED on connector ${connectorId.toString()}` ) } - resetConnectorStatus(connectorStatus) break case OCPP20TransactionEventEnumType.Started: - if (connectorStatus != null && connectorStatus.transactionStarted !== true) { + if (connectorStatus != null) { connectorStatus.transactionStarted = true - connectorStatus.transactionId = requestPayload.transactionInfo.transactionId - connectorStatus.transactionIdTag = requestPayload.idToken?.idToken - connectorStatus.transactionStart = new Date() - connectorStatus.transactionEnergyActiveImportRegisterValue = 0 - if (connectorId != null) { + connectorStatus.transactionPending = false + connectorStatus.transactionId ??= requestPayload.transactionInfo.transactionId + connectorStatus.transactionIdTag ??= requestPayload.idToken?.idToken + connectorStatus.transactionStart ??= new Date() + connectorStatus.transactionEnergyActiveImportRegisterValue ??= 0 + const isIdTokenAccepted = + payload.idTokenInfo == null || + payload.idTokenInfo.status === OCPP20AuthorizationStatusEnumType.Accepted + if (connectorId != null && isIdTokenAccepted) { sendAndSetConnectorStatus( chargingStation, connectorId, @@ -386,39 +359,31 @@ export class OCPP20ResponseService extends OCPPResponseService { logger.info( `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: IdToken info status: ${payload.idTokenInfo.status}` ) - // D01/D05: Stop transaction when idToken authorization is rejected by CSMS - const rejectedStatuses = new Set([ - OCPP20AuthorizationStatusEnumType.Blocked, - OCPP20AuthorizationStatusEnumType.Expired, - OCPP20AuthorizationStatusEnumType.Invalid, - OCPP20AuthorizationStatusEnumType.NoCredit, - ]) - if (rejectedStatuses.has(payload.idTokenInfo.status)) { + // E05.FR.09/FR.10 + E06.FR.04: Deauthorize transaction when idToken is not accepted by CSMS + if (payload.idTokenInfo.status !== OCPP20AuthorizationStatusEnumType.Accepted) { logger.warn( - `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: IdToken authorization rejected with status '${payload.idTokenInfo.status}', stopping active transaction per OCPP 2.0.1 spec (D01/D05)` + `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: IdToken authorization rejected with status '${payload.idTokenInfo.status}', de-authorizing transaction per E05.FR.09/E05.FR.10/E06.FR.04` ) - // Find the specific connector for this transaction - const connectorId = chargingStation.getConnectorIdByTransactionId( + const txConnectorId = chargingStation.getConnectorIdByTransactionId( requestPayload.transactionInfo.transactionId ) - const evseId = chargingStation.getEvseIdByTransactionId( + const txEvseId = chargingStation.getEvseIdByTransactionId( requestPayload.transactionInfo.transactionId ) - if (connectorId != null && evseId != null) { - logger.info( - `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Stopping transaction ${requestPayload.transactionInfo.transactionId} on EVSE ${evseId.toString()}, connector ${connectorId.toString()} due to rejected idToken` - ) - OCPP20ServiceUtils.requestStopTransaction(chargingStation, connectorId, evseId).catch( - (error: unknown) => { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Error stopping transaction ${requestPayload.transactionInfo.transactionId} on connector ${connectorId.toString()}:`, - error - ) - } - ) + if (txConnectorId != null && txEvseId != null) { + OCPP20ServiceUtils.requestDeauthorizeTransaction( + chargingStation, + txConnectorId, + txEvseId + ).catch((error: unknown) => { + logger.error( + `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Error de-authorizing transaction ${requestPayload.transactionInfo.transactionId} on connector ${txConnectorId.toString()}:`, + error + ) + }) } else { logger.warn( - `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Could not find connector for transaction ${requestPayload.transactionInfo.transactionId}, cannot stop transaction` + `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Could not find connector for transaction ${requestPayload.transactionInfo.transactionId}, cannot de-authorize` ) } } diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index dd299c2f..c22147d0 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -3,6 +3,7 @@ import { secondsToMilliseconds } from 'date-fns' import { type ChargingStation, resetConnectorStatus } from '../../../charging-station/index.js' import { OCPPError } from '../../../exception/index.js' import { + type ConnectorStatus, ConnectorStatusEnum, ErrorType, type JsonObject, @@ -23,6 +24,7 @@ import { OCPP20ReadingContextEnumType, } from '../../../types/ocpp/2.0/MeterValues.js' import { + OCPP20ChargingStateEnumType, type OCPP20EVSEType, OCPP20ReasonEnumType, type OCPP20TransactionEventOptions, @@ -290,13 +292,75 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { return { bytesLimit, itemsLimit } } + // E05.FR.09/FR.10 + E06.FR.04: Deauthorization flow when CSMS rejects idToken. + // Assumes StopTxOnInvalidId=true (simulator default). Sends Updated(Deauthorized, SuspendedEVSE) + // then Ended(Deauthorized, DeAuthorized) then cleanup. + public static async requestDeauthorizeTransaction ( + chargingStation: ChargingStation, + connectorId: number, + evseId?: number + ): Promise { + const connectorStatus = chargingStation.getConnectorStatus(connectorId) + if ( + (connectorStatus?.transactionStarted === true || + connectorStatus?.transactionPending === true) && + connectorStatus.transactionId != null + ) { + const transactionId = + typeof connectorStatus.transactionId === 'string' + ? connectorStatus.transactionId + : connectorStatus.transactionId.toString() + + await this.sendTransactionEvent( + chargingStation, + OCPP20TransactionEventEnumType.Updated, + OCPP20TriggerReasonEnumType.Deauthorized, + connectorId, + transactionId, + { + chargingState: OCPP20ChargingStateEnumType.SuspendedEVSE, + evseId, + } + ) + + const finalMeterValues = this.buildFinalMeterValues(connectorStatus) + + const response = await this.sendTransactionEvent( + chargingStation, + OCPP20TransactionEventEnumType.Ended, + OCPP20TriggerReasonEnumType.Deauthorized, + connectorId, + transactionId, + { + evseId, + meterValue: finalMeterValues.length > 0 ? finalMeterValues : undefined, + stoppedReason: OCPP20ReasonEnumType.DeAuthorized, + } + ) + + chargingStation.stopTxUpdatedInterval(connectorId) + resetConnectorStatus(connectorStatus) + await sendAndSetConnectorStatus(chargingStation, connectorId, ConnectorStatusEnum.Available) + + return response + } + throw new OCPPError( + ErrorType.PROPERTY_CONSTRAINT_VIOLATION, + `No active transaction on connector ${connectorId.toString()}` + ) + } + public static async requestStopTransaction ( chargingStation: ChargingStation, connectorId: number, evseId?: number ): Promise { const connectorStatus = chargingStation.getConnectorStatus(connectorId) - if (connectorStatus?.transactionStarted && connectorStatus.transactionId != null) { + if ( + (connectorStatus?.transactionStarted === true || + connectorStatus?.transactionPending === true) && + connectorStatus.transactionId != null + ) { let transactionId: string if (typeof connectorStatus.transactionId === 'string') { transactionId = connectorStatus.transactionId @@ -308,20 +372,7 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { } // F03.FR.04: Build final meter values for TransactionEvent(Ended) - const finalMeterValues: OCPP20MeterValue[] = [] - const energyValue = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0 - if (energyValue >= 0) { - finalMeterValues.push({ - sampledValue: [ - { - context: OCPP20ReadingContextEnumType.TRANSACTION_END, - measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, - value: energyValue, - }, - ], - timestamp: new Date(), - }) - } + const finalMeterValues = this.buildFinalMeterValues(connectorStatus) const response = await this.sendTransactionEvent( chargingStation, @@ -475,6 +526,24 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { throw error } } + + private static buildFinalMeterValues (connectorStatus: ConnectorStatus): OCPP20MeterValue[] { + const finalMeterValues: OCPP20MeterValue[] = [] + const energyValue = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0 + if (energyValue >= 0) { + finalMeterValues.push({ + sampledValue: [ + { + context: OCPP20ReadingContextEnumType.TRANSACTION_END, + measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, + value: energyValue, + }, + ], + timestamp: new Date(), + }) + } + return finalMeterValues + } } export function buildTransactionEvent ( chargingStation: ChargingStation, diff --git a/src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts b/src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts index ec5dbd58..b805a23f 100644 --- a/src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts +++ b/src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts @@ -20,7 +20,7 @@ import { OCPP20TriggerReasonEnumType, } from '../../../../types/ocpp/2.0/Transaction.js' import { OCPPVersion } from '../../../../types/ocpp/OCPPVersion.js' -import { logger, truncateId } from '../../../../utils/index.js' +import { generateUUID, logger, truncateId } from '../../../../utils/index.js' import { AuthContext, AuthenticationMethod, @@ -120,8 +120,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter { // OCPP 2.0: Authorization through TransactionEvent // According to OCPP 2.0.1 spec section G03 - Authorization - const tempTransactionId = - transactionId != null ? transactionId.toString() : `auth-${Date.now().toString()}` + const tempTransactionId = transactionId != null ? transactionId.toString() : generateUUID() // Get EVSE ID from connector const evseId = connectorId // In OCPP 2.0, connector maps to EVSE diff --git a/src/types/ConnectorStatus.ts b/src/types/ConnectorStatus.ts index 7b272de2..c6b407c1 100644 --- a/src/types/ConnectorStatus.ts +++ b/src/types/ConnectorStatus.ts @@ -43,6 +43,12 @@ export interface ConnectorStatus { * that occurs after the transaction has been authorized. */ transactionIdTokenSent?: boolean + /** + * OCPP 2.0.1: Transaction is pending CSMS acknowledgment via TransactionEvent response. + * Set by RequestStartTransaction handler to block duplicate starts before the response + * handler finalizes the transaction state with transactionStarted = true. + */ + transactionPending?: boolean transactionRemoteStarted?: boolean transactionSeqNo?: number transactionSetInterval?: NodeJS.Timeout diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GroupIdStop.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GroupIdStop.test.ts index 7631280b..0447013d 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GroupIdStop.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GroupIdStop.test.ts @@ -97,7 +97,7 @@ await describe('C09 - GroupId-based Stop Transaction Authorization', async () => assert.fail('Expected connectorStatus to be defined') } assert.strictEqual(connectorStatus.transactionGroupIdToken, GROUP_ID_TOKEN) - assert.strictEqual(connectorStatus.transactionStarted, true) + assert.strictEqual(connectorStatus.transactionPending, true) const isAuthorized = testableService.isAuthorizedToStopTransaction( mockStation, diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts index 30cbd128..82e065f7 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts @@ -144,7 +144,7 @@ await describe('F01 & F02 - Remote Start Transaction', async () => { } assert.strictEqual(connectorStatus.remoteStartId, 42) assert.strictEqual(connectorStatus.transactionIdTag, 'REMOTE_TOKEN_456') - assert.strictEqual(connectorStatus.transactionStarted, true) + assert.strictEqual(connectorStatus.transactionPending, true) assert.strictEqual(connectorStatus.transactionId, response.transactionId) OCPPAuthServiceFactory.clearAllInstances() diff --git a/tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts index 223315e7..d1c58150 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts @@ -108,8 +108,10 @@ await describe('D01 - TransactionEvent Response', async () => { await it('should not stop transaction when idTokenInfo status is Accepted', () => { // Arrange - const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () => - Promise.resolve({ status: 'Accepted' }) + const mockDeauthTransaction = mock.method( + OCPP20ServiceUtils, + 'requestDeauthorizeTransaction', + () => Promise.resolve({ status: 'Accepted' }) ) const payload: OCPP20TransactionEventResponse = { idTokenInfo: { @@ -122,13 +124,15 @@ await describe('D01 - TransactionEvent Response', async () => { testable.handleResponseTransactionEvent(station, payload, requestPayload) // Assert - assert.strictEqual(mockStopTransaction.mock.calls.length, 0) + assert.strictEqual(mockDeauthTransaction.mock.calls.length, 0) }) await it('should stop only the specific transaction when idTokenInfo status is Invalid', () => { // Arrange - const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () => - Promise.resolve({ status: 'Accepted' }) + const mockDeauthTransaction = mock.method( + OCPP20ServiceUtils, + 'requestDeauthorizeTransaction', + () => Promise.resolve({ status: 'Accepted' }) ) const payload: OCPP20TransactionEventResponse = { idTokenInfo: { @@ -141,16 +145,18 @@ await describe('D01 - TransactionEvent Response', async () => { testable.handleResponseTransactionEvent(station, payload, requestPayload) // Assert — only the specific connector (1) on EVSE (1) is stopped - assert.strictEqual(mockStopTransaction.mock.calls.length, 1) - assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[0], station) - assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[1], 1) - assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[2], 1) + assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1) + assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[0], station) + assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[1], 1) + assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[2], 1) }) await it('should stop only the specific transaction when idTokenInfo status is Blocked', () => { // Arrange - const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () => - Promise.resolve({ status: 'Accepted' }) + const mockDeauthTransaction = mock.method( + OCPP20ServiceUtils, + 'requestDeauthorizeTransaction', + () => Promise.resolve({ status: 'Accepted' }) ) const payload: OCPP20TransactionEventResponse = { idTokenInfo: { @@ -163,14 +169,16 @@ await describe('D01 - TransactionEvent Response', async () => { testable.handleResponseTransactionEvent(station, payload, requestPayload) // Assert - assert.strictEqual(mockStopTransaction.mock.calls.length, 1) - assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[0], station) + assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1) + assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[0], station) }) await it('should not stop transaction when only chargingPriority is present', () => { // Arrange - const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () => - Promise.resolve({ status: 'Accepted' }) + const mockDeauthTransaction = mock.method( + OCPP20ServiceUtils, + 'requestDeauthorizeTransaction', + () => Promise.resolve({ status: 'Accepted' }) ) const payload: OCPP20TransactionEventResponse = { chargingPriority: 5, @@ -181,13 +189,15 @@ await describe('D01 - TransactionEvent Response', async () => { testable.handleResponseTransactionEvent(station, payload, requestPayload) // Assert - assert.strictEqual(mockStopTransaction.mock.calls.length, 0) + assert.strictEqual(mockDeauthTransaction.mock.calls.length, 0) }) await it('should handle empty response without stopping transaction', () => { // Arrange - const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () => - Promise.resolve({ status: 'Accepted' }) + const mockDeauthTransaction = mock.method( + OCPP20ServiceUtils, + 'requestDeauthorizeTransaction', + () => Promise.resolve({ status: 'Accepted' }) ) const payload: OCPP20TransactionEventResponse = {} const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_ID) @@ -196,13 +206,15 @@ await describe('D01 - TransactionEvent Response', async () => { testable.handleResponseTransactionEvent(station, payload, requestPayload) // Assert - assert.strictEqual(mockStopTransaction.mock.calls.length, 0) + assert.strictEqual(mockDeauthTransaction.mock.calls.length, 0) }) await it('should stop only the specific transaction when idTokenInfo status is Expired', () => { // Arrange - const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () => - Promise.resolve({ status: 'Accepted' }) + const mockDeauthTransaction = mock.method( + OCPP20ServiceUtils, + 'requestDeauthorizeTransaction', + () => Promise.resolve({ status: 'Accepted' }) ) const payload: OCPP20TransactionEventResponse = { idTokenInfo: { @@ -215,13 +227,15 @@ await describe('D01 - TransactionEvent Response', async () => { testable.handleResponseTransactionEvent(station, payload, requestPayload) // Assert - assert.strictEqual(mockStopTransaction.mock.calls.length, 1) + assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1) }) await it('should stop only the specific transaction when idTokenInfo status is NoCredit', () => { // Arrange - const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () => - Promise.resolve({ status: 'Accepted' }) + const mockDeauthTransaction = mock.method( + OCPP20ServiceUtils, + 'requestDeauthorizeTransaction', + () => Promise.resolve({ status: 'Accepted' }) ) const payload: OCPP20TransactionEventResponse = { idTokenInfo: { @@ -234,13 +248,15 @@ await describe('D01 - TransactionEvent Response', async () => { testable.handleResponseTransactionEvent(station, payload, requestPayload) // Assert - assert.strictEqual(mockStopTransaction.mock.calls.length, 1) + assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1) }) await it('should not stop transaction when response has totalCost and updatedPersonalMessage', () => { // Arrange - const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () => - Promise.resolve({ status: 'Accepted' }) + const mockDeauthTransaction = mock.method( + OCPP20ServiceUtils, + 'requestDeauthorizeTransaction', + () => Promise.resolve({ status: 'Accepted' }) ) const payload: OCPP20TransactionEventResponse = { totalCost: 12.5, @@ -255,7 +271,7 @@ await describe('D01 - TransactionEvent Response', async () => { testable.handleResponseTransactionEvent(station, payload, requestPayload) // Assert - assert.strictEqual(mockStopTransaction.mock.calls.length, 0) + assert.strictEqual(mockDeauthTransaction.mock.calls.length, 0) }) await it('should stop only the targeted transaction on multi-EVSE station', () => { @@ -284,8 +300,10 @@ await describe('D01 - TransactionEvent Response', async () => { connector2.transactionId = txn2 } - const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () => - Promise.resolve({ status: 'Accepted' }) + const mockDeauthTransaction = mock.method( + OCPP20ServiceUtils, + 'requestDeauthorizeTransaction', + () => Promise.resolve({ status: 'Accepted' }) ) const payload: OCPP20TransactionEventResponse = { idTokenInfo: { @@ -302,8 +320,8 @@ await describe('D01 - TransactionEvent Response', async () => { ) // Assert — only 1 stop call targeting connector 1, EVSE 2 untouched - assert.strictEqual(mockStopTransaction.mock.calls.length, 1) - assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[0], multiStation) - assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[1], 1) + assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1) + assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[0], multiStation) + assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[1], 1) }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts index 88cbc11a..1f0915c0 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts @@ -20,12 +20,23 @@ import { buildTransactionEvent, OCPP20ServiceUtils, } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js' +import { OCPP20VariableManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js' +import { OCPPError } from '../../../../src/exception/OCPPError.js' +import { + AttributeEnumType, + OCPP20ComponentName, + OCPP20RequiredVariableName, +} from '../../../../src/types/index.js' import { ConnectorStatusEnum, OCPP20TransactionEventEnumType, OCPP20TriggerReasonEnumType, OCPPVersion, } from '../../../../src/types/index.js' +import { + OCPP20MeasurandEnumType, + OCPP20ReadingContextEnumType, +} from '../../../../src/types/ocpp/2.0/MeterValues.js' import { OCPP20ChargingStateEnumType, OCPP20IdTokenEnumType, @@ -2321,4 +2332,235 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { }) }) }) + + await describe('getTxUpdatedInterval', async () => { + let station: ChargingStation + + beforeEach(() => { + const mockTracking = createMockStationWithRequestTracking() + station = mockTracking.station + resetLimits(station) + }) + + afterEach(() => { + OCPP20VariableManager.getInstance().resetRuntimeOverrides() + standardCleanup() + }) + + await it('should return default interval when TxUpdatedInterval is not configured', () => { + const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station) + + assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL * 1000) + }) + + await it('should return configured interval in milliseconds', () => { + OCPP20VariableManager.getInstance().setVariables(station, [ + { + attributeType: AttributeEnumType.Actual, + attributeValue: '60', + component: { name: OCPP20ComponentName.SampledDataCtrlr }, + variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval }, + }, + ]) + + const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station) + + assert.strictEqual(interval, 60000) + }) + + await it('should return default for non-numeric value', () => { + OCPP20VariableManager.getInstance().setVariables(station, [ + { + attributeType: AttributeEnumType.Actual, + attributeValue: 'abc', + component: { name: OCPP20ComponentName.SampledDataCtrlr }, + variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval }, + }, + ]) + + const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station) + + assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL * 1000) + }) + + await it('should return default for zero value', () => { + OCPP20VariableManager.getInstance().setVariables(station, [ + { + attributeType: AttributeEnumType.Actual, + attributeValue: '0', + component: { name: OCPP20ComponentName.SampledDataCtrlr }, + variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval }, + }, + ]) + + const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station) + + assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL * 1000) + }) + + await it('should return default for negative value', () => { + OCPP20VariableManager.getInstance().setVariables(station, [ + { + attributeType: AttributeEnumType.Actual, + attributeValue: '-10', + component: { name: OCPP20ComponentName.SampledDataCtrlr }, + variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval }, + }, + ]) + + const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station) + + assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL * 1000) + }) + }) + + await describe('requestDeauthorizeTransaction', async () => { + let mockTracking: MockStationWithTracking + + beforeEach(() => { + mockTracking = createMockStationWithRequestTracking() + resetConnectorTransactionState(mockTracking.station) + }) + + afterEach(() => { + standardCleanup() + }) + + await it('should send Updated(Deauthorized, SuspendedEVSE) then Ended(Deauthorized, DeAuthorized)', async () => { + // Arrange + const connectorId = 1 + const transactionId = generateUUID() + const connectorStatus = mockTracking.station.getConnectorStatus(connectorId) + assert.notStrictEqual(connectorStatus, undefined) + if (connectorStatus != null) { + connectorStatus.transactionStarted = true + connectorStatus.transactionId = transactionId + connectorStatus.transactionEnergyActiveImportRegisterValue = 0 + } + + // Act + await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1) + + // Assert + const txEvents = mockTracking.sentRequests.filter(r => r.command === 'TransactionEvent') + assert.strictEqual(txEvents.length, 2) + + const updatedEvent = txEvents[0].payload + assert.strictEqual(updatedEvent.eventType, OCPP20TransactionEventEnumType.Updated) + assert.strictEqual(updatedEvent.triggerReason, OCPP20TriggerReasonEnumType.Deauthorized) + assert.strictEqual(updatedEvent.chargingState, OCPP20ChargingStateEnumType.SuspendedEVSE) + + const endedEvent = txEvents[1].payload + assert.strictEqual(endedEvent.eventType, OCPP20TransactionEventEnumType.Ended) + assert.strictEqual(endedEvent.triggerReason, OCPP20TriggerReasonEnumType.Deauthorized) + assert.strictEqual(endedEvent.stoppedReason, OCPP20ReasonEnumType.DeAuthorized) + }) + + await it('should include final meter values in Ended event', async () => { + // Arrange + const connectorId = 2 + const transactionId = generateUUID() + const connectorStatus = mockTracking.station.getConnectorStatus(connectorId) + assert.notStrictEqual(connectorStatus, undefined) + if (connectorStatus != null) { + connectorStatus.transactionStarted = true + connectorStatus.transactionId = transactionId + connectorStatus.transactionEnergyActiveImportRegisterValue = 1500 + } + + // Act + await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 2) + + // Assert + const txEvents = mockTracking.sentRequests.filter(r => r.command === 'TransactionEvent') + assert.strictEqual(txEvents.length, 2) + + const endedPayload = txEvents[1].payload + assert.notStrictEqual(endedPayload.meterValue, undefined) + const meterValues = endedPayload.meterValue as { + sampledValue: { context: string; measurand: string; value: number }[] + timestamp: Date + }[] + assert.strictEqual(meterValues.length, 1) + assert.strictEqual(meterValues[0].sampledValue.length, 1) + assert.strictEqual( + meterValues[0].sampledValue[0].measurand, + OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER + ) + assert.strictEqual(meterValues[0].sampledValue[0].value, 1500) + assert.strictEqual( + meterValues[0].sampledValue[0].context, + OCPP20ReadingContextEnumType.TRANSACTION_END + ) + }) + + await it('should reset connector status after deauthorization', async () => { + // Arrange + const connectorId = 1 + const transactionId = generateUUID() + const connectorStatus = mockTracking.station.getConnectorStatus(connectorId) + assert.notStrictEqual(connectorStatus, undefined) + if (connectorStatus != null) { + connectorStatus.transactionStarted = true + connectorStatus.transactionId = transactionId + connectorStatus.transactionEnergyActiveImportRegisterValue = 100 + } + + // Act + await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1) + + // Assert + const postStatus = mockTracking.station.getConnectorStatus(connectorId) + assert.notStrictEqual(postStatus, undefined) + if (postStatus != null) { + assert.strictEqual(postStatus.transactionStarted, false) + assert.strictEqual(postStatus.transactionId, undefined) + } + }) + + await it('should throw if no active transaction', async () => { + const connectorId = 1 + + await assert.rejects( + OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1), + (error: unknown) => { + assert.ok(error instanceof OCPPError) + return true + } + ) + }) + + await it('should propagate error and skip cleanup if Updated event fails', async () => { + const connectorId = 1 + const transactionId = generateUUID() + const connectorStatus = mockTracking.station.getConnectorStatus(connectorId) + if (connectorStatus != null) { + connectorStatus.transactionStarted = true + connectorStatus.transactionId = transactionId + connectorStatus.transactionEnergyActiveImportRegisterValue = 0 + } + + const originalSend = OCPP20ServiceUtils.sendTransactionEvent.bind(OCPP20ServiceUtils) + const sendMock = mock.method(OCPP20ServiceUtils, 'sendTransactionEvent', () => { + sendMock.mock.restore() + OCPP20ServiceUtils.sendTransactionEvent = originalSend + return Promise.reject(new Error('Network failure')) + }) + + await assert.rejects( + OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1), + (error: unknown) => { + assert.ok(error instanceof Error) + assert.strictEqual(error.message, 'Network failure') + return true + } + ) + + const postStatus = mockTracking.station.getConnectorStatus(connectorId) + if (postStatus != null) { + assert.strictEqual(postStatus.transactionStarted, true) + assert.strictEqual(postStatus.transactionId, transactionId) + } + }) + }) }) -- 2.43.0