From 0d25748bfdc427e530144246bf60e92d3abaea6d Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Wed, 18 Mar 2026 19:32:25 +0100 Subject: [PATCH] refactor(ocpp2): extract buildTransactionEvent as standalone function, remove dead context code MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Extract buildTransactionEvent from OCPP20ServiceUtils static method to exported standalone function, consistent with buildStatusNotificationRequest and buildMeterValue. Remove unused context overload from buildTransactionEvent and sendTransactionEvent — all production callers pass triggerReason directly. Remove dead code: OCPP20TransactionContext interface (not in OCPP 2.0.1 specs), selectTriggerReason method, TransactionContextFixtures, and associated tests. --- .../ocpp/2.0/OCPP20RequestService.ts | 36 +- .../ocpp/2.0/OCPP20ServiceUtils.ts | 492 +++------ src/charging-station/ocpp/index.ts | 3 +- src/types/ocpp/2.0/Transaction.ts | 53 - ...CPP20ServiceUtils-TransactionEvent.test.ts | 990 +++++------------- .../ocpp/2.0/OCPP20TestUtils.ts | 205 +--- 6 files changed, 430 insertions(+), 1349 deletions(-) diff --git a/src/charging-station/ocpp/2.0/OCPP20RequestService.ts b/src/charging-station/ocpp/2.0/OCPP20RequestService.ts index 40d5b8d3..313301de 100644 --- a/src/charging-station/ocpp/2.0/OCPP20RequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20RequestService.ts @@ -1,7 +1,6 @@ import type { ValidateFunction } from 'ajv' import type { ChargingStation } from '../../../charging-station/index.js' -import type { OCPP20TransactionEventOptions } from '../../../types/ocpp/2.0/Transaction.js' import type { OCPPResponseService } from '../OCPPResponseService.js' import { OCPPError } from '../../../exception/index.js' @@ -13,8 +12,6 @@ import { type JsonType, OCPP20RequestCommand, type OCPP20SignCertificateRequest, - OCPP20TransactionEventEnumType, - OCPP20TriggerReasonEnumType, OCPPVersion, type RequestParams, } from '../../../types/index.js' @@ -23,7 +20,7 @@ import { OCPPRequestService } from '../OCPPRequestService.js' import { buildStatusNotificationRequest } from '../OCPPServiceUtils.js' import { generatePkcs10Csr } from './Asn1DerUtils.js' import { OCPP20Constants } from './OCPP20Constants.js' -import { OCPP20ServiceUtils } from './OCPP20ServiceUtils.js' +import { buildTransactionEvent, OCPP20ServiceUtils } from './OCPP20ServiceUtils.js' const moduleName = 'OCPP20RequestService' @@ -204,35 +201,8 @@ export class OCPP20RequestService extends OCPPRequestService { commandParams.status as ConnectorStatusEnum, commandParams.evseId as number | undefined ) as unknown as Request - case OCPP20RequestCommand.TRANSACTION_EVENT: { - const eventType = commandParams.eventType as OCPP20TransactionEventEnumType - const triggerReason: OCPP20TriggerReasonEnumType = - commandParams.triggerReason != null - ? (commandParams.triggerReason as OCPP20TriggerReasonEnumType) - : eventType === OCPP20TransactionEventEnumType.Ended - ? OCPP20TriggerReasonEnumType.RemoteStop - : OCPP20TriggerReasonEnumType.Authorized - const evse = commandParams.evse as undefined | { connectorId?: number; id?: number } - const connectorId: number = - commandParams.connectorId != null - ? (commandParams.connectorId as number) - : (evse?.connectorId ?? evse?.id ?? 1) - const transactionId: string = - commandParams.transactionId != null - ? (commandParams.transactionId as string) - : eventType === OCPP20TransactionEventEnumType.Ended - ? (chargingStation.getConnectorStatus(connectorId)?.transactionId?.toString() ?? - generateUUID()) - : generateUUID() - return OCPP20ServiceUtils.buildTransactionEvent( - chargingStation, - eventType, - triggerReason, - connectorId, - transactionId, - commandParams as unknown as OCPP20TransactionEventOptions - ) as unknown as Request - } + case OCPP20RequestCommand.TRANSACTION_EVENT: + return buildTransactionEvent(chargingStation, commandParams) as unknown as Request default: { // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError(). const errorMsg = `Unsupported OCPP command ${commandName as string} for payload building` diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index 968ec58a..e684ec4a 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/unified-signatures */ - import { secondsToMilliseconds } from 'date-fns' import { type ChargingStation, resetConnectorStatus } from '../../../charging-station/index.js' @@ -7,6 +5,7 @@ import { OCPPError } from '../../../exception/index.js' import { ConnectorStatusEnum, ErrorType, + type JsonObject, OCPP20ComponentName, OCPP20IncomingRequestCommand, OCPP20RequestCommand, @@ -26,19 +25,18 @@ import { import { type OCPP20EVSEType, OCPP20ReasonEnumType, - type OCPP20TransactionContext, type OCPP20TransactionEventOptions, type OCPP20TransactionType, } from '../../../types/ocpp/2.0/Transaction.js' import { Constants, convertToIntOrNaN, + generateUUID, logger, validateIdentifierString, } from '../../../utils/index.js' import { getConfigurationKey } from '../../ConfigurationKeyUtils.js' import { OCPPServiceUtils, sendAndSetConnectorStatus } from '../OCPPServiceUtils.js' -import { OCPP20Constants } from './OCPP20Constants.js' import { OCPP20VariableManager } from './OCPP20VariableManager.js' const moduleName = 'OCPP20ServiceUtils' @@ -86,169 +84,6 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { [OCPP20RequestCommand.TRANSACTION_EVENT, 'TransactionEvent'], ] - /** - * Build a TransactionEvent request according to OCPP 2.0.1 specification - * - * This method creates a properly formatted TransactionEventRequest that complies with - * OCPP 2.0.1 requirements including F01, E01, E06, and TriggerReason specifications. - * - * Key features: - * - Automatic per-EVSE sequence number management - * - Full TriggerReason validation (21 enum values) - * - EVSE/connector mapping and validation - * - Transaction UUID handling - * - Comprehensive parameter validation - * @param chargingStation - The charging station instance - * @param eventType - Transaction event type (Started, Updated, Ended) - * @param triggerReason - Reason that triggered the event (21 OCPP 2.0.1 values) - * @param connectorId - Connector identifier - * @param transactionId - Transaction UUID (required for all events) - * @param options - Optional parameters for the transaction event - * @param options.evseId - * @param options.idToken - * @param options.meterValue - * @param options.chargingState - * @param options.stoppedReason - * @param options.remoteStartId - * @param options.cableMaxCurrent - * @param options.numberOfPhasesUsed - * @param options.offline - * @param options.reservationId - * @param options.customData - * @returns Promise - Built transaction event request - * @throws {OCPPError} When parameters are invalid or EVSE mapping fails - */ - public static buildTransactionEvent ( - chargingStation: ChargingStation, - eventType: OCPP20TransactionEventEnumType, - context: OCPP20TransactionContext, - connectorId: number, - transactionId: string, - options?: OCPP20TransactionEventOptions - ): OCPP20TransactionEventRequest - public static buildTransactionEvent ( - chargingStation: ChargingStation, - eventType: OCPP20TransactionEventEnumType, - triggerReason: OCPP20TriggerReasonEnumType, - connectorId: number, - transactionId: string, - options?: OCPP20TransactionEventOptions - ): OCPP20TransactionEventRequest - public static buildTransactionEvent ( - chargingStation: ChargingStation, - eventType: OCPP20TransactionEventEnumType, - triggerReasonOrContext: OCPP20TransactionContext | OCPP20TriggerReasonEnumType, - connectorId: number, - transactionId: string, - options: OCPP20TransactionEventOptions = {} - ): OCPP20TransactionEventRequest { - const isContext = typeof triggerReasonOrContext === 'object' - const triggerReason = isContext - ? this.selectTriggerReason(eventType, triggerReasonOrContext) - : triggerReasonOrContext - - // Validate transaction ID format (must be non-empty string ≤36 characters per OCPP 2.0.1) - if (!validateIdentifierString(transactionId, 36)) { - const errorMsg = `Invalid transaction ID format (must be non-empty string ≤36 characters): ${transactionId}` - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.buildTransactionEvent: ${errorMsg}` - ) - throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg) - } - - const evseId = options.evseId ?? chargingStation.getEvseIdByConnectorId(connectorId) - if (evseId == null) { - const errorMsg = `Cannot find EVSE ID for connector ${connectorId.toString()}` - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.buildTransactionEvent: ${errorMsg}` - ) - throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg) - } - - const connectorStatus = chargingStation.getConnectorStatus(connectorId) - if (connectorStatus == null) { - const errorMsg = `Cannot find connector status for connector ${connectorId.toString()}` - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.buildTransactionEvent: ${errorMsg}` - ) - throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg) - } - - // Per-EVSE sequence number management (OCPP 2.0.1 §1.3.2.1) - if (connectorStatus.transactionSeqNo == null) { - connectorStatus.transactionSeqNo = 0 - } else { - connectorStatus.transactionSeqNo = connectorStatus.transactionSeqNo + 1 - } - - // Build EVSE object (E01.FR.16: only include in first TransactionEvent after EV connected) - let evse: OCPP20EVSEType | undefined - if (connectorStatus.transactionEvseSent !== true) { - evse = { id: evseId } - if (connectorId !== evseId) { - evse.connectorId = connectorId - } - connectorStatus.transactionEvseSent = true - } - - const transactionInfo: OCPP20TransactionType = { - transactionId: transactionId as UUIDv4, - } - - if (options.chargingState !== undefined) { - transactionInfo.chargingState = options.chargingState - } - if (options.stoppedReason !== undefined) { - transactionInfo.stoppedReason = options.stoppedReason - } - if (options.remoteStartId !== undefined) { - transactionInfo.remoteStartId = options.remoteStartId - } - - const transactionEventRequest: OCPP20TransactionEventRequest = { - eventType, - seqNo: connectorStatus.transactionSeqNo, - timestamp: new Date(), - transactionInfo, - triggerReason, - } - - // E01.FR.16: Include evse only in first TransactionEvent - if (evse !== undefined) { - transactionEventRequest.evse = evse - } - - // E03.FR.01: Include idToken only once per transaction (first event after authorization) - if (options.idToken !== undefined && connectorStatus.transactionIdTokenSent !== true) { - transactionEventRequest.idToken = options.idToken - connectorStatus.transactionIdTokenSent = true - } - if (options.meterValue !== undefined && options.meterValue.length > 0) { - transactionEventRequest.meterValue = options.meterValue - } - if (options.cableMaxCurrent !== undefined) { - transactionEventRequest.cableMaxCurrent = options.cableMaxCurrent - } - if (options.numberOfPhasesUsed !== undefined) { - transactionEventRequest.numberOfPhasesUsed = options.numberOfPhasesUsed - } - if (options.offline !== undefined) { - transactionEventRequest.offline = options.offline - } - if (options.reservationId !== undefined) { - transactionEventRequest.reservationId = options.reservationId - } - if (options.customData !== undefined) { - transactionEventRequest.customData = options.customData - } - - logger.debug( - `${chargingStation.logPrefix()} ${moduleName}.buildTransactionEvent: Building TransactionEvent for trigger ${triggerReason}` - ) - - return transactionEventRequest - } - /** * OCPP 2.0 Incoming Request Service validator configurations * @returns Array of validator configuration tuples @@ -535,146 +370,6 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { } } - /** - * Intelligently select appropriate TriggerReason based on transaction context - * - * This method implements the E02.FR.17 requirement for context-aware TriggerReason selection. - * It analyzes the transaction context to determine the most appropriate TriggerReason according - * to OCPP 2.0.1 specification and best practices. - * - * Selection Logic (by priority): - * 1. Remote commands (RequestStartTransaction, RequestStopTransaction, etc.) -> RemoteStart/RemoteStop - * 2. Authorization events (token presented) -> Authorized/StopAuthorized/Deauthorized - * 3. Cable physical actions -> CablePluggedIn - * 4. Charging state transitions -> ChargingStateChanged - * 5. System events (EV detection, communication) -> EVDetected/EVDeparted/EVCommunicationLost - * 6. Meter value events -> MeterValuePeriodic/MeterValueClock - * 7. Energy/Time limits -> EnergyLimitReached/TimeLimitReached - * 8. Abnormal conditions -> AbnormalCondition - * @param eventType - The transaction event type (Started, Updated, Ended) - * @param context - Context information describing the trigger source and details - * @returns OCPP20TriggerReasonEnumType - The most appropriate trigger reason - */ - public static selectTriggerReason ( - eventType: OCPP20TransactionEventEnumType, - context: OCPP20TransactionContext - ): OCPP20TriggerReasonEnumType { - const candidates = OCPP20Constants.TriggerReasonMapping.filter( - entry => entry.source === context.source - ) - - for (const entry of candidates) { - if (context.source === 'remote_command' && context.command != null) { - if ( - (context.command === OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION && - entry.triggerReason === OCPP20TriggerReasonEnumType.RemoteStart) || - (context.command === OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION && - entry.triggerReason === OCPP20TriggerReasonEnumType.RemoteStop) || - (context.command === OCPP20IncomingRequestCommand.RESET && - entry.triggerReason === OCPP20TriggerReasonEnumType.ResetCommand) || - (context.command === OCPP20IncomingRequestCommand.TRIGGER_MESSAGE && - entry.triggerReason === OCPP20TriggerReasonEnumType.Trigger) || - (context.command === OCPP20IncomingRequestCommand.UNLOCK_CONNECTOR && - entry.triggerReason === OCPP20TriggerReasonEnumType.UnlockCommand) - ) { - return entry.triggerReason - } - } - - if (context.source === 'local_authorization' && context.authorizationMethod != null) { - if (context.isDeauthorized === true) { - if (entry.triggerReason === OCPP20TriggerReasonEnumType.Deauthorized) { - return entry.triggerReason - } - } else if ( - (context.authorizationMethod === 'groupIdToken' || - context.authorizationMethod === 'idToken') && - entry.triggerReason === OCPP20TriggerReasonEnumType.Authorized - ) { - return entry.triggerReason - } else if ( - context.authorizationMethod === 'stopAuthorized' && - entry.triggerReason === OCPP20TriggerReasonEnumType.StopAuthorized - ) { - return entry.triggerReason - } - } - - if (context.source === 'cable_action' && context.cableState != null) { - if ( - (context.cableState === 'detected' && - entry.triggerReason === OCPP20TriggerReasonEnumType.EVDetected) || - (context.cableState === 'plugged_in' && - entry.triggerReason === OCPP20TriggerReasonEnumType.CablePluggedIn) || - (context.cableState === 'unplugged' && - entry.triggerReason === OCPP20TriggerReasonEnumType.EVDeparted) - ) { - return entry.triggerReason - } - } - - if ( - context.source === 'charging_state' && - context.chargingStateChange != null && - entry.triggerReason === OCPP20TriggerReasonEnumType.ChargingStateChanged - ) { - return entry.triggerReason - } - - if (context.source === 'system_event' && context.systemEvent != null) { - if ( - (context.systemEvent === 'ev_communication_lost' && - entry.triggerReason === OCPP20TriggerReasonEnumType.EVCommunicationLost) || - (context.systemEvent === 'ev_connect_timeout' && - entry.triggerReason === OCPP20TriggerReasonEnumType.EVConnectTimeout) || - (context.systemEvent === 'ev_departed' && - entry.triggerReason === OCPP20TriggerReasonEnumType.EVDeparted) || - (context.systemEvent === 'ev_detected' && - entry.triggerReason === OCPP20TriggerReasonEnumType.EVDetected) - ) { - return entry.triggerReason - } - } - - if (context.source === 'meter_value') { - if ( - (context.isSignedDataReceived === true && - entry.triggerReason === OCPP20TriggerReasonEnumType.SignedDataReceived) || - (context.isPeriodicMeterValue === true && - entry.triggerReason === OCPP20TriggerReasonEnumType.MeterValuePeriodic) || - (context.isSignedDataReceived !== true && - context.isPeriodicMeterValue !== true && - entry.triggerReason === OCPP20TriggerReasonEnumType.MeterValueClock) - ) { - return entry.triggerReason - } - } - - if ( - (context.source === 'energy_limit' && - entry.triggerReason === OCPP20TriggerReasonEnumType.EnergyLimitReached) || - (context.source === 'time_limit' && - entry.triggerReason === OCPP20TriggerReasonEnumType.TimeLimitReached) || - (context.source === 'external_limit' && - entry.triggerReason === OCPP20TriggerReasonEnumType.ChargingRateChanged) - ) { - return entry.triggerReason - } - - if ( - context.source === 'abnormal_condition' && - entry.triggerReason === OCPP20TriggerReasonEnumType.AbnormalCondition - ) { - return entry.triggerReason - } - } - - logger.warn( - `${moduleName}.selectTriggerReason: No matching context found for source '${context.source}', defaulting to Trigger` - ) - return OCPP20TriggerReasonEnumType.Trigger - } - public static async sendQueuedTransactionEvents ( chargingStation: ChargingStation, connectorId: number @@ -715,38 +410,15 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { } } - public static async sendTransactionEvent ( - chargingStation: ChargingStation, - eventType: OCPP20TransactionEventEnumType, - context: OCPP20TransactionContext, - connectorId: number, - transactionId: string, - options?: OCPP20TransactionEventOptions - ): Promise public static async sendTransactionEvent ( chargingStation: ChargingStation, eventType: OCPP20TransactionEventEnumType, triggerReason: OCPP20TriggerReasonEnumType, connectorId: number, transactionId: string, - options?: OCPP20TransactionEventOptions - ): Promise - // Implementation with union type + type guard - public static async sendTransactionEvent ( - chargingStation: ChargingStation, - eventType: OCPP20TransactionEventEnumType, - triggerReasonOrContext: OCPP20TransactionContext | OCPP20TriggerReasonEnumType, - connectorId: number, - transactionId: string, options: OCPP20TransactionEventOptions = {} ): Promise { try { - // Type guard: distinguish between context object and direct trigger reason - const isContext = typeof triggerReasonOrContext === 'object' - const triggerReason = isContext - ? this.selectTriggerReason(eventType, triggerReasonOrContext) - : triggerReasonOrContext - const connectorStatus = chargingStation.getConnectorStatus(connectorId) if (connectorStatus == null) { const errorMsg = `Cannot find connector status for connector ${connectorId.toString()}` @@ -758,7 +430,7 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { // Offline: build and queue pre-built payload (sent as-is via rawPayload on reconnect) if (!chargingStation.isWebSocketConnectionOpened()) { - const transactionEventRequest = OCPP20ServiceUtils.buildTransactionEvent( + const transactionEventRequest = buildTransactionEvent( chargingStation, eventType, triggerReason, @@ -804,3 +476,161 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { } } } +export function buildTransactionEvent ( + chargingStation: ChargingStation, + eventType: OCPP20TransactionEventEnumType, + triggerReason: OCPP20TriggerReasonEnumType, + connectorId: number, + transactionId: string, + options?: OCPP20TransactionEventOptions +): OCPP20TransactionEventRequest +export function buildTransactionEvent ( + chargingStation: ChargingStation, + commandParams: JsonObject +): OCPP20TransactionEventRequest +/** + * + * @param chargingStation + * @param eventTypeOrParams + * @param triggerReasonArg + * @param connectorIdArg + * @param transactionIdArg + * @param options + */ +export function buildTransactionEvent ( + chargingStation: ChargingStation, + eventTypeOrParams: JsonObject | OCPP20TransactionEventEnumType, + triggerReasonArg?: OCPP20TriggerReasonEnumType, + connectorIdArg?: number, + transactionIdArg?: string, + options: OCPP20TransactionEventOptions = {} +): OCPP20TransactionEventRequest { + let eventType: OCPP20TransactionEventEnumType + let triggerReason: OCPP20TriggerReasonEnumType + let connectorId: number + let transactionId: string + + if (typeof eventTypeOrParams === 'object') { + const params = eventTypeOrParams + eventType = params.eventType as OCPP20TransactionEventEnumType + triggerReason = + params.triggerReason != null + ? (params.triggerReason as OCPP20TriggerReasonEnumType) + : eventType === OCPP20TransactionEventEnumType.Ended + ? OCPP20TriggerReasonEnumType.RemoteStop + : OCPP20TriggerReasonEnumType.Authorized + const evse = params.evse as undefined | { connectorId?: number; id?: number } + connectorId = + params.connectorId != null + ? (params.connectorId as number) + : (evse?.connectorId ?? evse?.id ?? 1) + transactionId = + params.transactionId != null + ? (params.transactionId as string) + : eventType === OCPP20TransactionEventEnumType.Ended + ? (chargingStation.getConnectorStatus(connectorId)?.transactionId?.toString() ?? + generateUUID()) + : generateUUID() + options = params as unknown as OCPP20TransactionEventOptions + } else { + eventType = eventTypeOrParams + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + triggerReason = triggerReasonArg! + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + connectorId = connectorIdArg! + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + transactionId = transactionIdArg! + } + + if (!validateIdentifierString(transactionId, 36)) { + const errorMsg = `Invalid transaction ID format (must be non-empty string ≤36 characters): ${transactionId}` + logger.error(`${chargingStation.logPrefix()} ${moduleName}.buildTransactionEvent: ${errorMsg}`) + throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg) + } + + const evseId = options.evseId ?? chargingStation.getEvseIdByConnectorId(connectorId) + if (evseId == null) { + const errorMsg = `Cannot find EVSE ID for connector ${connectorId.toString()}` + logger.error(`${chargingStation.logPrefix()} ${moduleName}.buildTransactionEvent: ${errorMsg}`) + throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg) + } + + const connectorStatus = chargingStation.getConnectorStatus(connectorId) + if (connectorStatus == null) { + const errorMsg = `Cannot find connector status for connector ${connectorId.toString()}` + logger.error(`${chargingStation.logPrefix()} ${moduleName}.buildTransactionEvent: ${errorMsg}`) + throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg) + } + + if (connectorStatus.transactionSeqNo == null) { + connectorStatus.transactionSeqNo = 0 + } else { + connectorStatus.transactionSeqNo = connectorStatus.transactionSeqNo + 1 + } + + // E01.FR.16: only include EVSE in first TransactionEvent + let evse: OCPP20EVSEType | undefined + if (connectorStatus.transactionEvseSent !== true) { + evse = { id: evseId } + if (connectorId !== evseId) { + evse.connectorId = connectorId + } + connectorStatus.transactionEvseSent = true + } + + const transactionInfo: OCPP20TransactionType = { + transactionId: transactionId as UUIDv4, + } + + if (options.chargingState !== undefined) { + transactionInfo.chargingState = options.chargingState + } + if (options.stoppedReason !== undefined) { + transactionInfo.stoppedReason = options.stoppedReason + } + if (options.remoteStartId !== undefined) { + transactionInfo.remoteStartId = options.remoteStartId + } + + const transactionEventRequest: OCPP20TransactionEventRequest = { + eventType, + seqNo: connectorStatus.transactionSeqNo, + timestamp: new Date(), + transactionInfo, + triggerReason, + } + + if (evse !== undefined) { + transactionEventRequest.evse = evse + } + + // E03.FR.01: Include idToken only once per transaction + if (options.idToken !== undefined && connectorStatus.transactionIdTokenSent !== true) { + transactionEventRequest.idToken = options.idToken + connectorStatus.transactionIdTokenSent = true + } + if (options.meterValue !== undefined && options.meterValue.length > 0) { + transactionEventRequest.meterValue = options.meterValue + } + if (options.cableMaxCurrent !== undefined) { + transactionEventRequest.cableMaxCurrent = options.cableMaxCurrent + } + if (options.numberOfPhasesUsed !== undefined) { + transactionEventRequest.numberOfPhasesUsed = options.numberOfPhasesUsed + } + if (options.offline !== undefined) { + transactionEventRequest.offline = options.offline + } + if (options.reservationId !== undefined) { + transactionEventRequest.reservationId = options.reservationId + } + if (options.customData !== undefined) { + transactionEventRequest.customData = options.customData + } + + logger.debug( + `${chargingStation.logPrefix()} ${moduleName}.buildTransactionEvent: Building TransactionEvent for trigger ${triggerReason}` + ) + + return transactionEventRequest +} diff --git a/src/charging-station/ocpp/index.ts b/src/charging-station/ocpp/index.ts index 0aab4035..960b0952 100644 --- a/src/charging-station/ocpp/index.ts +++ b/src/charging-station/ocpp/index.ts @@ -8,13 +8,14 @@ export { OCPP16ResponseService } from './1.6/OCPP16ResponseService.js' export { OCPP20IncomingRequestService } from './2.0/OCPP20IncomingRequestService.js' export { OCPP20RequestService } from './2.0/OCPP20RequestService.js' export { OCPP20ResponseService } from './2.0/OCPP20ResponseService.js' -export { OCPP20ServiceUtils } from './2.0/OCPP20ServiceUtils.js' +export { buildTransactionEvent, OCPP20ServiceUtils } from './2.0/OCPP20ServiceUtils.js' export { OCPP20VariableManager } from './2.0/OCPP20VariableManager.js' export { OCPPAuthServiceFactory } from './auth/services/OCPPAuthServiceFactory.js' export { OCPPIncomingRequestService } from './OCPPIncomingRequestService.js' export { OCPPRequestService } from './OCPPRequestService.js' export { buildMeterValue, + buildStatusNotificationRequest, buildTransactionEndMeterValue, getMessageTypeString, isIdTagAuthorized, diff --git a/src/types/ocpp/2.0/Transaction.ts b/src/types/ocpp/2.0/Transaction.ts index bf11c9d5..415efe47 100644 --- a/src/types/ocpp/2.0/Transaction.ts +++ b/src/types/ocpp/2.0/Transaction.ts @@ -2,7 +2,6 @@ import type { JsonObject } from '../../JsonType.js' import type { UUIDv4 } from '../../UUID.js' import type { CustomDataType } from './Common.js' import type { OCPP20MeterValue } from './MeterValues.js' -import type { OCPP20IncomingRequestCommand } from './Requests.js' export enum CostKindEnumType { CarbonDioxideEmission = 'CarbonDioxideEmission', @@ -267,58 +266,6 @@ export interface OCPP20MessageContentType extends JsonObject { format: OCPP20MessageFormatEnumType language?: string } - -/** - * Context information for intelligent TriggerReason selection - * Used by OCPP20ServiceUtils.selectTriggerReason() to determine appropriate trigger reason - */ -export interface OCPP20TransactionContext { - /** Abnormal condition type (for abnormal_condition source) */ - abnormalCondition?: string - - /** Authorization method used (for local_authorization source) */ - authorizationMethod?: 'groupIdToken' | 'idToken' | 'stopAuthorized' - - /** Cable connection state (for cable_action source) */ - cableState?: 'detected' | 'plugged_in' | 'unplugged' - - /** Charging state change details (for charging_state source) */ - chargingStateChange?: { - from?: OCPP20ChargingStateEnumType - to?: OCPP20ChargingStateEnumType - } - - /** Specific command that triggered the event (for remote_command source) */ - command?: OCPP20IncomingRequestCommand - - hasRemoteStartId?: boolean - - isDeauthorized?: boolean - - /** Additional context flags */ - isOffline?: boolean - - /** Whether this is a periodic meter value event */ - isPeriodicMeterValue?: boolean - - /** Whether this is a signed data reception event */ - isSignedDataReceived?: boolean - /** Source of the transaction event - command, authorization, physical action, etc. */ - source: - | 'abnormal_condition' - | 'cable_action' - | 'charging_state' - | 'energy_limit' - | 'external_limit' - | 'local_authorization' - | 'meter_value' - | 'remote_command' - | 'system_event' - | 'time_limit' - /** System event details (for system_event source) */ - systemEvent?: 'ev_communication_lost' | 'ev_connect_timeout' | 'ev_departed' | 'ev_detected' -} - /** * Optional parameters for building and sending TransactionEvent requests. * Aligned with OCPP 2.0.1 TransactionEvent.req optional fields. 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 dd1d4ac5..88cbc11a 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts @@ -16,20 +16,21 @@ import { afterEach, beforeEach, describe, it, mock } from 'node:test' import type { ChargingStation } from '../../../../src/charging-station/ChargingStation.js' import type { EmptyObject } from '../../../../src/types/index.js' -import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js' +import { + buildTransactionEvent, + OCPP20ServiceUtils, +} from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js' import { ConnectorStatusEnum, OCPP20TransactionEventEnumType, OCPP20TriggerReasonEnumType, OCPPVersion, } from '../../../../src/types/index.js' -import { OCPP20IncomingRequestCommand } from '../../../../src/types/ocpp/2.0/Requests.js' import { OCPP20ChargingStateEnumType, OCPP20IdTokenEnumType, type OCPP20IdTokenType, OCPP20ReasonEnumType, - type OCPP20TransactionContext, type OCPP20TransactionType, } from '../../../../src/types/ocpp/2.0/Transaction.js' import { Constants, generateUUID } from '../../../../src/utils/index.js' @@ -42,7 +43,6 @@ import { type MockStationWithTracking, resetConnectorTransactionState, resetLimits, - TransactionContextFixtures, } from './OCPP20TestUtils.js' // ============================================================================ // Transaction Flow Patterns for Parameterized Testing @@ -59,7 +59,6 @@ const TRANSACTION_FLOWS = [ id: 'cableFirst', includeIdToken: false, name: 'E02 - Cable-First', - startContext: TransactionContextFixtures.cablePluggedIn(), }, { description: 'E03 IdToken-First', @@ -67,7 +66,6 @@ const TRANSACTION_FLOWS = [ id: 'idTokenFirst', includeIdToken: true, name: 'E03 - IdToken-First', - startContext: TransactionContextFixtures.idTokenAuthorized(), }, { description: 'Remote Start', @@ -75,7 +73,6 @@ const TRANSACTION_FLOWS = [ id: 'remoteStart', includeIdToken: false, name: 'Remote Start', - startContext: TransactionContextFixtures.remoteStart(), }, ] as const @@ -116,7 +113,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // Reset sequence number to simulate new transaction OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) - const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent( + const transactionEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, triggerReason, @@ -151,7 +148,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // Build first event (Started) - const startEvent = OCPP20ServiceUtils.buildTransactionEvent( + const startEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, @@ -160,7 +157,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { ) // Build second event (Updated) - const updateEvent = OCPP20ServiceUtils.buildTransactionEvent( + const updateEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.MeterValuePeriodic, @@ -169,7 +166,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { ) // Build third event (Ended) - const endEvent = OCPP20ServiceUtils.buildTransactionEvent( + const endEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Ended, OCPP20TriggerReasonEnumType.StopAuthorized, @@ -205,7 +202,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { reservationId: 67890, } - const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent( + const transactionEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.ChargingStateChanged, @@ -237,7 +234,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { 'this-string-is-way-too-long-for-a-valid-transaction-id-exceeds-36-chars' try { - OCPP20ServiceUtils.buildTransactionEvent( + buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, @@ -284,7 +281,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) for (const triggerReason of triggerReasons) { - const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent( + const transactionEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, triggerReason, @@ -360,7 +357,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { const connectorId = 1 // First, build a transaction event to set sequence number - OCPP20ServiceUtils.buildTransactionEvent( + buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, @@ -397,7 +394,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) - const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent( + const transactionEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, @@ -458,7 +455,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) - const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent( + const transactionEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, @@ -478,626 +475,276 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { }) }) - // FR: E01.FR.04 - TriggerReason selection based on transaction context - await describe('Context-Aware TriggerReason Selection', async () => { - await describe('selectTriggerReason', async () => { - await it('should select RemoteStart for remote_command context with RequestStartTransaction', () => { - const context: OCPP20TransactionContext = { - command: OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION, - source: 'remote_command', - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Started, - context - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.RemoteStart) - }) - - await it('should select RemoteStop for remote_command context with RequestStopTransaction', () => { - const context: OCPP20TransactionContext = { - command: OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION, - source: 'remote_command', - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Ended, - context - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.RemoteStop) - }) - - await it('should select UnlockCommand for remote_command context with UnlockConnector', () => { - const context: OCPP20TransactionContext = { - command: OCPP20IncomingRequestCommand.UNLOCK_CONNECTOR, - source: 'remote_command', - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Updated, - context - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.UnlockCommand) - }) - - await it('should select ResetCommand for remote_command context with Reset', () => { - const context: OCPP20TransactionContext = { - command: OCPP20IncomingRequestCommand.RESET, - source: 'remote_command', - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Ended, - context - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.ResetCommand) - }) - - await it('should select Trigger for remote_command context with TriggerMessage', () => { - const context: OCPP20TransactionContext = { - command: OCPP20IncomingRequestCommand.TRIGGER_MESSAGE, - source: 'remote_command', - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Updated, - context - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.Trigger) - }) - - await it('should select Authorized for local_authorization context with idToken', () => { - const context: OCPP20TransactionContext = { - authorizationMethod: 'idToken', - source: 'local_authorization', - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Started, - context - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.Authorized) - }) - - await it('should select StopAuthorized for local_authorization context with stopAuthorized', () => { - const context: OCPP20TransactionContext = { - authorizationMethod: 'stopAuthorized', - source: 'local_authorization', - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Ended, - context - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.StopAuthorized) - }) - - await it('should select Deauthorized when isDeauthorized flag is true', () => { - const context: OCPP20TransactionContext = { - authorizationMethod: 'idToken', - isDeauthorized: true, - source: 'local_authorization', - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Ended, - context - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.Deauthorized) - }) - - await it('should select ChargingStateChanged for charging_state context', () => { - const context: OCPP20TransactionContext = { - chargingStateChange: { - from: OCPP20ChargingStateEnumType.Idle, - to: OCPP20ChargingStateEnumType.Charging, - }, - source: 'charging_state', - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Updated, - context - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.ChargingStateChanged) - }) - - await it('should select MeterValuePeriodic for meter_value context with periodic flag', () => { - const context: OCPP20TransactionContext = { - isPeriodicMeterValue: true, - source: 'meter_value', - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Updated, - context - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.MeterValuePeriodic) - }) - - await it('should select MeterValueClock for meter_value context without periodic flag', () => { - const context: OCPP20TransactionContext = { - isPeriodicMeterValue: false, - source: 'meter_value', - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Updated, - context - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.MeterValueClock) - }) - - await it('should select SignedDataReceived when isSignedDataReceived flag is true', () => { - const context: OCPP20TransactionContext = { - isSignedDataReceived: true, - source: 'meter_value', - } + await describe('sendTransactionEvent with context parameter', async () => { + await it('should send TransactionEvent with context-aware TriggerReason selection', async () => { + const connectorId = 1 + const transactionId = generateUUID() - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Updated, - context - ) + const response = await OCPP20ServiceUtils.sendTransactionEvent( + mockStation, + OCPP20TransactionEventEnumType.Started, + OCPP20TriggerReasonEnumType.CablePluggedIn, + connectorId, + transactionId + ) - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.SignedDataReceived) - }) + // Validate response structure + assert.notStrictEqual(response, undefined) + assert.strictEqual(typeof response, 'object') + }) - await it('should select appropriate system events for system_event context', () => { - const testCases = [ - { - expected: OCPP20TriggerReasonEnumType.EVDeparted, - systemEvent: 'ev_departed' as const, - }, - { - expected: OCPP20TriggerReasonEnumType.EVDetected, - systemEvent: 'ev_detected' as const, - }, - { - expected: OCPP20TriggerReasonEnumType.EVCommunicationLost, - systemEvent: 'ev_communication_lost' as const, - }, - { - expected: OCPP20TriggerReasonEnumType.EVConnectTimeout, - systemEvent: 'ev_connect_timeout' as const, + await it('should handle context-aware error scenarios gracefully', async () => { + // Create error mock for this test + const { station: errorMockChargingStation } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 1, + evseConfiguration: { evsesCount: 1 }, + heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL, + ocppRequestService: { + requestHandler: () => { + throw new Error('Context test error') }, - ] - - for (const testCase of testCases) { - const context: OCPP20TransactionContext = { - source: 'system_event', - systemEvent: testCase.systemEvent, - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Updated, - context - ) - - assert.strictEqual(triggerReason, testCase.expected) - } + }, + stationInfo: { + ocppStrictCompliance: true, + ocppVersion: OCPPVersion.VERSION_201, + }, + websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL, }) - await it('should select EnergyLimitReached for energy_limit context', () => { - const context: OCPP20TransactionContext = { - source: 'energy_limit', - } + const connectorId = 1 + const transactionId = generateUUID() - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( + try { + await OCPP20ServiceUtils.sendTransactionEvent( + errorMockChargingStation, OCPP20TransactionEventEnumType.Ended, - context + OCPP20TriggerReasonEnumType.AbnormalCondition, + connectorId, + transactionId ) + throw new Error('Should have thrown error') + } catch (error) { + assert.ok((error as Error).message.includes('Context test error')) + } + }) + }) - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.EnergyLimitReached) - }) + await describe('Backward Compatibility', async () => { + await it('should maintain compatibility with existing buildTransactionEvent calls', () => { + const connectorId = 1 + const transactionId = generateUUID() - await it('should select TimeLimitReached for time_limit context', () => { - const context: OCPP20TransactionContext = { - source: 'time_limit', - } + OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Ended, - context - ) + // Old method call should still work + const oldEvent = buildTransactionEvent( + mockStation, + OCPP20TransactionEventEnumType.Started, + OCPP20TriggerReasonEnumType.Authorized, + connectorId, + transactionId + ) - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.TimeLimitReached) - }) + assert.strictEqual(oldEvent.eventType, OCPP20TransactionEventEnumType.Started) + assert.strictEqual(oldEvent.triggerReason, OCPP20TriggerReasonEnumType.Authorized) + assert.strictEqual(oldEvent.seqNo, 0) + }) - await it('should select AbnormalCondition for abnormal_condition context', () => { - const context: OCPP20TransactionContext = { - abnormalCondition: 'OverCurrent', - source: 'abnormal_condition', - } + await it('should maintain compatibility with existing sendTransactionEvent calls', async () => { + const connectorId = 1 + const transactionId = generateUUID() - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Ended, - context - ) + // Old method call should still work + const response = await OCPP20ServiceUtils.sendTransactionEvent( + mockStation, + OCPP20TransactionEventEnumType.Started, + OCPP20TriggerReasonEnumType.Authorized, + connectorId, + transactionId + ) - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.AbnormalCondition) - }) + assert.notStrictEqual(response, undefined) + assert.strictEqual(typeof response, 'object') + }) + }) + }) - await it('should handle priority ordering with multiple applicable contexts', () => { - // Test context with multiple applicable triggers - priority should be respected - const context: OCPP20TransactionContext = { - cableState: 'plugged_in', // Even lower priority - command: OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION, - isDeauthorized: true, // Lower priority but should be overridden - source: 'remote_command', // High priority - } + // ========================================================================== + // Parameterized Transaction Flow Tests (E02, E03, Remote Start) + // ========================================================================== + await describe('Transaction Flow Patterns', async () => { + let mockStation: ChargingStation - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Started, - context - ) + beforeEach(() => { + const { station } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 3, + evseConfiguration: { evsesCount: 3 }, + heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL, + ocppRequestService: { + requestHandler: async () => Promise.resolve({} as EmptyObject), + }, + stationInfo: { + ocppStrictCompliance: true, + ocppVersion: OCPPVersion.VERSION_201, + }, + websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL, + }) + mockStation = station + resetLimits(mockStation) + }) - // Should select RemoteStart (priority 1) over Deauthorized (priority 2) or CablePluggedIn (priority 3) - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.RemoteStart) - }) + afterEach(() => { + standardCleanup() + }) - await it('should fallback to Trigger for unknown context source', () => { - const context: OCPP20TransactionContext = { - source: 'unknown_source' as OCPP20TransactionContext['source'], // Invalid source to test fallback - } + for (const { + description, + expectedStartTrigger, + id, + includeIdToken, + name, + } of TRANSACTION_FLOWS) { + await describe(`${name} Flow`, async () => { + await it(`should build correct Started event for ${description}`, () => { + const connectorId = 1 + const transactionId = generateUUID() + const idToken: OCPP20IdTokenType | undefined = includeIdToken + ? { idToken: `${id.toUpperCase()}_TOKEN_001`, type: OCPP20IdTokenEnumType.ISO14443 } + : undefined + + OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( + const startedEvent = buildTransactionEvent( + mockStation, OCPP20TransactionEventEnumType.Started, - context + expectedStartTrigger, + connectorId, + transactionId, + idToken != null ? { idToken } : undefined ) - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.Trigger) - }) + assert.strictEqual(startedEvent.eventType, OCPP20TransactionEventEnumType.Started) + assert.strictEqual(startedEvent.triggerReason, expectedStartTrigger) + assert.strictEqual(startedEvent.seqNo, 0) + assert.strictEqual(startedEvent.transactionInfo.transactionId, transactionId) - await it('should fallback to Trigger for incomplete context', () => { - const context: OCPP20TransactionContext = { - source: 'remote_command', - // Missing command field + if (includeIdToken) { + assert.notStrictEqual(startedEvent.idToken, undefined) + assert.strictEqual(startedEvent.idToken?.idToken, `${id.toUpperCase()}_TOKEN_001`) } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Started, - context - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.Trigger) }) - }) - await describe('buildTransactionEvent with context parameter', async () => { - await it('should build TransactionEvent with auto-selected TriggerReason from context', () => { + await it(`should support complete ${description} transaction lifecycle`, () => { const connectorId = 1 const transactionId = generateUUID() - const context: OCPP20TransactionContext = { - command: OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION, - source: 'remote_command', - } + const idToken: OCPP20IdTokenType | undefined = includeIdToken + ? { + idToken: `${id.toUpperCase()}_LIFECYCLE_001`, + type: OCPP20IdTokenEnumType.ISO14443, + } + : undefined OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) - const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent( + // Step 1: Started event + const startedEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, - context, + expectedStartTrigger, connectorId, - transactionId - ) - - assert.strictEqual(transactionEvent.eventType, OCPP20TransactionEventEnumType.Started) - assert.strictEqual( - transactionEvent.triggerReason, - OCPP20TriggerReasonEnumType.RemoteStart + transactionId, + idToken != null ? { idToken } : undefined ) - assert.strictEqual(transactionEvent.seqNo, 0) - assert.strictEqual(transactionEvent.transactionInfo.transactionId, transactionId) - }) - - await it('should pass through optional parameters correctly', () => { - const connectorId = 2 - const transactionId = generateUUID() - const context: OCPP20TransactionContext = { - authorizationMethod: 'idToken', - source: 'local_authorization', - } - const options = { - chargingState: OCPP20ChargingStateEnumType.Charging, - idToken: { - idToken: 'CONTEXT_TEST_TOKEN', - type: OCPP20IdTokenEnumType.ISO14443, - }, - } - const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent( + // Step 2: Charging state change + const chargingEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, - context, + OCPP20TriggerReasonEnumType.ChargingStateChanged, connectorId, transactionId, - options - ) - - assert.strictEqual(transactionEvent.triggerReason, OCPP20TriggerReasonEnumType.Authorized) - assert.strictEqual(transactionEvent.idToken?.idToken, 'CONTEXT_TEST_TOKEN') - assert.strictEqual( - transactionEvent.transactionInfo.chargingState, - OCPP20ChargingStateEnumType.Charging + { chargingState: OCPP20ChargingStateEnumType.Charging } ) - }) - }) - - await describe('sendTransactionEvent with context parameter', async () => { - await it('should send TransactionEvent with context-aware TriggerReason selection', async () => { - const connectorId = 1 - const transactionId = generateUUID() - const context: OCPP20TransactionContext = { - cableState: 'plugged_in', - source: 'cable_action', - } - const response = await OCPP20ServiceUtils.sendTransactionEvent( + // Step 3: Ended event + const endedEvent = buildTransactionEvent( mockStation, - OCPP20TransactionEventEnumType.Started, - context, + OCPP20TransactionEventEnumType.Ended, + OCPP20TriggerReasonEnumType.StopAuthorized, connectorId, transactionId ) - // Validate response structure - assert.notStrictEqual(response, undefined) - assert.strictEqual(typeof response, 'object') - }) - - await it('should handle context-aware error scenarios gracefully', async () => { - // Create error mock for this test - const { station: errorMockChargingStation } = createMockChargingStation({ - baseName: TEST_CHARGING_STATION_BASE_NAME, - connectorsCount: 1, - evseConfiguration: { evsesCount: 1 }, - heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL, - ocppRequestService: { - requestHandler: () => { - throw new Error('Context test error') - }, - }, - stationInfo: { - ocppStrictCompliance: true, - ocppVersion: OCPPVersion.VERSION_201, - }, - websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL, - }) - - const connectorId = 1 - const transactionId = generateUUID() - const context: OCPP20TransactionContext = { - abnormalCondition: 'TestError', - source: 'abnormal_condition', - } + // Validate event sequence + assert.strictEqual(startedEvent.seqNo, 0) + assert.strictEqual(chargingEvent.seqNo, 1) + assert.strictEqual(endedEvent.seqNo, 2) - try { - await OCPP20ServiceUtils.sendTransactionEvent( - errorMockChargingStation, - OCPP20TransactionEventEnumType.Ended, - context, - connectorId, - transactionId - ) - throw new Error('Should have thrown error') - } catch (error) { - assert.ok((error as Error).message.includes('Context test error')) - } + // All events share same transaction ID + assert.strictEqual(startedEvent.transactionInfo.transactionId, transactionId) + assert.strictEqual(chargingEvent.transactionInfo.transactionId, transactionId) + assert.strictEqual(endedEvent.transactionInfo.transactionId, transactionId) }) - }) - await describe('Backward Compatibility', async () => { - await it('should maintain compatibility with existing buildTransactionEvent calls', () => { - const connectorId = 1 - const transactionId = generateUUID() + await it(`should maintain independent sequence numbers on different connectors for ${description}`, () => { + const connector1 = 1 + const connector2 = 2 + const transaction1Id = generateUUID() + const transaction2Id = generateUUID() - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) + OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connector1) + OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connector2) - // Old method call should still work - const oldEvent = OCPP20ServiceUtils.buildTransactionEvent( + // Start transaction on connector 1 + const conn1Event1 = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, - connectorId, - transactionId + expectedStartTrigger, + connector1, + transaction1Id ) - assert.strictEqual(oldEvent.eventType, OCPP20TransactionEventEnumType.Started) - assert.strictEqual(oldEvent.triggerReason, OCPP20TriggerReasonEnumType.Authorized) - assert.strictEqual(oldEvent.seqNo, 0) - }) - - await it('should maintain compatibility with existing sendTransactionEvent calls', async () => { - const connectorId = 1 - const transactionId = generateUUID() - - // Old method call should still work - const response = await OCPP20ServiceUtils.sendTransactionEvent( + // Start transaction on connector 2 + const conn2Event1 = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, - connectorId, - transactionId + expectedStartTrigger, + connector2, + transaction2Id ) - assert.notStrictEqual(response, undefined) - assert.strictEqual(typeof response, 'object') - }) - }) - }) - - // ========================================================================== - // Parameterized Transaction Flow Tests (E02, E03, Remote Start) - // ========================================================================== - await describe('Transaction Flow Patterns', async () => { - for (const { - description, - expectedStartTrigger, - id, - includeIdToken, - name, - startContext, - } of TRANSACTION_FLOWS) { - await describe(`${name} Flow`, async () => { - await it(`should select ${expectedStartTrigger} trigger for ${description} transaction start`, () => { - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Started, - startContext - ) - assert.strictEqual(triggerReason, expectedStartTrigger) - }) - - await it(`should build correct Started event for ${description}`, () => { - const connectorId = 1 - const transactionId = generateUUID() - const idToken: OCPP20IdTokenType | undefined = includeIdToken - ? { idToken: `${id.toUpperCase()}_TOKEN_001`, type: OCPP20IdTokenEnumType.ISO14443 } - : undefined - - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) - - const startedEvent = OCPP20ServiceUtils.buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - expectedStartTrigger, - connectorId, - transactionId, - idToken != null ? { idToken } : undefined - ) - - assert.strictEqual(startedEvent.eventType, OCPP20TransactionEventEnumType.Started) - assert.strictEqual(startedEvent.triggerReason, expectedStartTrigger) - assert.strictEqual(startedEvent.seqNo, 0) - assert.strictEqual(startedEvent.transactionInfo.transactionId, transactionId) - - if (includeIdToken) { - assert.notStrictEqual(startedEvent.idToken, undefined) - assert.strictEqual(startedEvent.idToken?.idToken, `${id.toUpperCase()}_TOKEN_001`) - } - }) - - await it(`should support complete ${description} transaction lifecycle`, () => { - const connectorId = 1 - const transactionId = generateUUID() - const idToken: OCPP20IdTokenType | undefined = includeIdToken - ? { - idToken: `${id.toUpperCase()}_LIFECYCLE_001`, - type: OCPP20IdTokenEnumType.ISO14443, - } - : undefined - - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) - - // Step 1: Started event - const startedEvent = OCPP20ServiceUtils.buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - expectedStartTrigger, - connectorId, - transactionId, - idToken != null ? { idToken } : undefined - ) - - // Step 2: Charging state change - const chargingEvent = OCPP20ServiceUtils.buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.ChargingStateChanged, - connectorId, - transactionId, - { chargingState: OCPP20ChargingStateEnumType.Charging } - ) - - // Step 3: Ended event - const endedEvent = OCPP20ServiceUtils.buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Ended, - OCPP20TriggerReasonEnumType.StopAuthorized, - connectorId, - transactionId - ) - - // Validate event sequence - assert.strictEqual(startedEvent.seqNo, 0) - assert.strictEqual(chargingEvent.seqNo, 1) - assert.strictEqual(endedEvent.seqNo, 2) - - // All events share same transaction ID - assert.strictEqual(startedEvent.transactionInfo.transactionId, transactionId) - assert.strictEqual(chargingEvent.transactionInfo.transactionId, transactionId) - assert.strictEqual(endedEvent.transactionInfo.transactionId, transactionId) - }) - - await it(`should maintain independent sequence numbers on different connectors for ${description}`, () => { - const connector1 = 1 - const connector2 = 2 - const transaction1Id = generateUUID() - const transaction2Id = generateUUID() - - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connector1) - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connector2) - - // Start transaction on connector 1 - const conn1Event1 = OCPP20ServiceUtils.buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - expectedStartTrigger, - connector1, - transaction1Id - ) + // Update connector 1 + const conn1Event2 = buildTransactionEvent( + mockStation, + OCPP20TransactionEventEnumType.Updated, + OCPP20TriggerReasonEnumType.ChargingStateChanged, + connector1, + transaction1Id + ) - // Start transaction on connector 2 - const conn2Event1 = OCPP20ServiceUtils.buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Started, - expectedStartTrigger, - connector2, - transaction2Id - ) + // Update connector 2 + const conn2Event2 = buildTransactionEvent( + mockStation, + OCPP20TransactionEventEnumType.Updated, + OCPP20TriggerReasonEnumType.ChargingStateChanged, + connector2, + transaction2Id + ) - // Update connector 1 - const conn1Event2 = OCPP20ServiceUtils.buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.ChargingStateChanged, - connector1, - transaction1Id - ) + // Verify independent sequence numbers + assert.strictEqual(conn1Event1.seqNo, 0) + assert.strictEqual(conn1Event2.seqNo, 1) + assert.strictEqual(conn2Event1.seqNo, 0) + assert.strictEqual(conn2Event2.seqNo, 1) - // Update connector 2 - const conn2Event2 = OCPP20ServiceUtils.buildTransactionEvent( - mockStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.ChargingStateChanged, - connector2, - transaction2Id - ) - - // Verify independent sequence numbers - assert.strictEqual(conn1Event1.seqNo, 0) - assert.strictEqual(conn1Event2.seqNo, 1) - assert.strictEqual(conn2Event1.seqNo, 0) - assert.strictEqual(conn2Event2.seqNo, 1) - - // Verify independent transaction IDs - assert.strictEqual(conn1Event1.transactionInfo.transactionId, transaction1Id) - assert.strictEqual(conn2Event1.transactionInfo.transactionId, transaction2Id) - }) + // Verify independent transaction IDs + assert.strictEqual(conn1Event1.transactionInfo.transactionId, transaction1Id) + assert.strictEqual(conn2Event1.transactionInfo.transactionId, transaction2Id) }) - } - }) + }) + } // ========================================================================== // E02 Cable-First Specific Tests @@ -1115,7 +762,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // Step 1: Cable plugged in (Started) - const cablePluggedEvent = OCPP20ServiceUtils.buildTransactionEvent( + const cablePluggedEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.CablePluggedIn, @@ -1124,7 +771,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { ) // Step 2: EV detected (Updated) - const evDetectedEvent = OCPP20ServiceUtils.buildTransactionEvent( + const evDetectedEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.EVDetected, @@ -1133,7 +780,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { ) // Step 3: Charging starts (Updated with ChargingStateChanged) - const chargingStartedEvent = OCPP20ServiceUtils.buildTransactionEvent( + const chargingStartedEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.ChargingStateChanged, @@ -1165,7 +812,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // Start transaction with cable plug - const startEvent = OCPP20ServiceUtils.buildTransactionEvent( + const startEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.CablePluggedIn, @@ -1174,7 +821,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { ) // End transaction with EV departure (cable removal) - const endEvent = OCPP20ServiceUtils.buildTransactionEvent( + const endEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Ended, OCPP20TriggerReasonEnumType.EVDeparted, @@ -1200,28 +847,28 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // Build full cable-first flow const events = [ - OCPP20ServiceUtils.buildTransactionEvent( + buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.CablePluggedIn, connectorId, transactionId ), - OCPP20ServiceUtils.buildTransactionEvent( + buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.EVDetected, connectorId, transactionId ), - OCPP20ServiceUtils.buildTransactionEvent( + buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.Authorized, connectorId, transactionId ), - OCPP20ServiceUtils.buildTransactionEvent( + buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.ChargingStateChanged, @@ -1319,7 +966,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // Cable-first flow with suspended state const events = [ // 1. Cable plugged - OCPP20ServiceUtils.buildTransactionEvent( + buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.CablePluggedIn, @@ -1327,7 +974,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { transactionId ), // 2. Start charging - OCPP20ServiceUtils.buildTransactionEvent( + buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.ChargingStateChanged, @@ -1336,7 +983,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { { chargingState: OCPP20ChargingStateEnumType.Charging } ), // 3. Suspended by EV - OCPP20ServiceUtils.buildTransactionEvent( + buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.ChargingStateChanged, @@ -1345,7 +992,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { { chargingState: OCPP20ChargingStateEnumType.SuspendedEV } ), // 4. Resume charging - OCPP20ServiceUtils.buildTransactionEvent( + buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.ChargingStateChanged, @@ -1354,7 +1001,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { { chargingState: OCPP20ChargingStateEnumType.Charging } ), // 5. EV departed - OCPP20ServiceUtils.buildTransactionEvent( + buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Ended, OCPP20TriggerReasonEnumType.EVDeparted, @@ -1375,88 +1022,6 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { }) }) - await describe('Context-Based Cable Event Trigger Selection', async () => { - await it('should select CablePluggedIn from cable_action context with plugged_in state', () => { - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Started, - TransactionContextFixtures.cablePluggedIn() - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.CablePluggedIn) - }) - - await it('should select EVDetected from cable_action context with detected state', () => { - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Updated, - TransactionContextFixtures.evDetected() - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.EVDetected) - }) - - await it('should select EVDeparted from cable_action context with unplugged state', () => { - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Ended, - TransactionContextFixtures.evDeparted() - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.EVDeparted) - }) - }) - }) - - // ========================================================================== - // E03 IdToken-First Specific Tests - // ========================================================================== - await describe('E03 - IdToken-First Pre-Authorization', async () => { - beforeEach(() => { - resetConnectorTransactionState(mockStation) - }) - - await describe('E03.FR.13 - Trigger Reason Selection', async () => { - await it('should select groupIdToken trigger for group authorization', () => { - const context: OCPP20TransactionContext = { - authorizationMethod: 'groupIdToken', - source: 'local_authorization', - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Started, - context - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.Authorized) - }) - - await it('should differentiate IdToken-first from Cable-first by trigger reason', () => { - // IdToken-first: Authorized trigger - const idTokenFirstContext: OCPP20TransactionContext = { - authorizationMethod: 'idToken', - source: 'local_authorization', - } - - // Cable-first: CablePluggedIn trigger - const cableFirstContext: OCPP20TransactionContext = { - cableState: 'plugged_in', - source: 'cable_action', - } - - const idTokenTrigger = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Started, - idTokenFirstContext - ) - - const cableTrigger = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Started, - cableFirstContext - ) - - assert.strictEqual(idTokenTrigger, OCPP20TriggerReasonEnumType.Authorized) - assert.strictEqual(cableTrigger, OCPP20TriggerReasonEnumType.CablePluggedIn) - assert.notStrictEqual(idTokenTrigger, cableTrigger) - }) - }) - await describe('E03.FR.01 - IdToken in TransactionEvent', async () => { await it('should include idToken in first TransactionEvent after authorization', () => { const connectorId = 1 @@ -1469,7 +1034,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // Build Started event with idToken (E03.FR.01: IdToken must be in first event) - const startedEvent = OCPP20ServiceUtils.buildTransactionEvent( + const startedEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, @@ -1498,7 +1063,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // First event includes idToken - const startedEvent = OCPP20ServiceUtils.buildTransactionEvent( + const startedEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, @@ -1508,7 +1073,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { ) // Second event should NOT include idToken (flag is set after first inclusion) - const updatedEvent = OCPP20ServiceUtils.buildTransactionEvent( + const updatedEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.ChargingStateChanged, @@ -1533,7 +1098,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { type: OCPP20IdTokenEnumType.ISO14443, } - const rfidEvent = OCPP20ServiceUtils.buildTransactionEvent( + const rfidEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, @@ -1557,7 +1122,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { type: OCPP20IdTokenEnumType.eMAID, } - const emaidEvent = OCPP20ServiceUtils.buildTransactionEvent( + const emaidEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, @@ -1583,7 +1148,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // E03 Step 1: IdToken presented and authorized (Started with Authorized trigger) - const authorizedEvent = OCPP20ServiceUtils.buildTransactionEvent( + const authorizedEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, @@ -1593,7 +1158,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { ) // E03 Step 2: Cable connected (Updated event) - const cableConnectedEvent = OCPP20ServiceUtils.buildTransactionEvent( + const cableConnectedEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.CablePluggedIn, @@ -1602,7 +1167,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { ) // E03 Step 3: Charging starts - const chargingEvent = OCPP20ServiceUtils.buildTransactionEvent( + const chargingEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.ChargingStateChanged, @@ -1612,7 +1177,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { ) // E03 Step 4: Transaction ends - const endedEvent = OCPP20ServiceUtils.buildTransactionEvent( + const endedEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Ended, OCPP20TriggerReasonEnumType.StopAuthorized, @@ -1663,7 +1228,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { connectorStatus.transactionIdTokenSent = undefined } - const e03Start = OCPP20ServiceUtils.buildTransactionEvent( + const e03Start = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, @@ -1678,7 +1243,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { connectorStatus.transactionIdTokenSent = undefined } - const e02Start = OCPP20ServiceUtils.buildTransactionEvent( + const e02Start = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.CablePluggedIn, @@ -1708,7 +1273,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // E03.FR.05: User authorizes with IdToken - const authorizedEvent = OCPP20ServiceUtils.buildTransactionEvent( + const authorizedEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, @@ -1718,7 +1283,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { ) // E03.FR.06: Cable not connected within timeout - transaction ends with Timeout - const timeoutEvent = OCPP20ServiceUtils.buildTransactionEvent( + const timeoutEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Ended, OCPP20TriggerReasonEnumType.EVConnectTimeout, @@ -1745,21 +1310,6 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { }) await describe('Authorization Status in E03', async () => { - await it('should support Deauthorized trigger for rejected authorization', () => { - const context: OCPP20TransactionContext = { - authorizationMethod: 'idToken', - isDeauthorized: true, - source: 'local_authorization', - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Ended, - context - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.Deauthorized) - }) - await it('should handle transaction end after token revocation', () => { const connectorId = 1 const transactionId = generateUUID() @@ -1771,7 +1321,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // Transaction started with authorization - const startEvent = OCPP20ServiceUtils.buildTransactionEvent( + const startEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, @@ -1781,7 +1331,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { ) // Transaction ended due to deauthorization (e.g., token revoked mid-session) - const revokedEvent = OCPP20ServiceUtils.buildTransactionEvent( + const revokedEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Ended, OCPP20TriggerReasonEnumType.Deauthorized, @@ -1793,20 +1343,6 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { assert.strictEqual(revokedEvent.eventType, OCPP20TransactionEventEnumType.Ended) assert.strictEqual(revokedEvent.triggerReason, OCPP20TriggerReasonEnumType.Deauthorized) }) - - await it('should support StopAuthorized trigger for normal transaction end', () => { - const context: OCPP20TransactionContext = { - authorizationMethod: 'stopAuthorized', - source: 'local_authorization', - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Ended, - context - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.StopAuthorized) - }) }) await describe('E03.FR.07/08 - Sequence Numbers and Transaction ID', async () => { @@ -1821,7 +1357,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) const events = [ - OCPP20ServiceUtils.buildTransactionEvent( + buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, @@ -1829,28 +1365,28 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { transactionId, { idToken } ), - OCPP20ServiceUtils.buildTransactionEvent( + buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.CablePluggedIn, connectorId, transactionId ), - OCPP20ServiceUtils.buildTransactionEvent( + buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.ChargingStateChanged, connectorId, transactionId ), - OCPP20ServiceUtils.buildTransactionEvent( + buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.MeterValuePeriodic, connectorId, transactionId ), - OCPP20ServiceUtils.buildTransactionEvent( + buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Ended, OCPP20TriggerReasonEnumType.StopAuthorized, @@ -1876,7 +1412,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // E03.FR.08: transactionId MUST be unique assert.notStrictEqual(transaction1Id, transaction2Id) - const event1 = OCPP20ServiceUtils.buildTransactionEvent( + const event1 = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, @@ -1886,7 +1422,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) - const event2 = OCPP20ServiceUtils.buildTransactionEvent( + const event2 = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, @@ -2566,7 +2102,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId) // Send initial Started event - const startEvent = OCPP20ServiceUtils.buildTransactionEvent( + const startEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, @@ -2577,7 +2113,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // Send multiple periodic events (simulating timer ticks) for (let i = 1; i <= 3; i++) { - const periodicEvent = OCPP20ServiceUtils.buildTransactionEvent( + const periodicEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.MeterValuePeriodic, @@ -2661,7 +2197,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // Simulate full transaction lifecycle with periodic updates // 1. Started event (seqNo: 0) - const startEvent = OCPP20ServiceUtils.buildTransactionEvent( + const startEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, @@ -2672,7 +2208,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // 2. Multiple periodic updates (seqNo: 1, 2, 3) for (let i = 1; i <= 3; i++) { - const updateEvent = OCPP20ServiceUtils.buildTransactionEvent( + const updateEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.MeterValuePeriodic, @@ -2683,7 +2219,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { } // 3. Ended event (seqNo: 4) - const endEvent = OCPP20ServiceUtils.buildTransactionEvent( + const endEvent = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Ended, OCPP20TriggerReasonEnumType.StopAuthorized, @@ -2702,14 +2238,14 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, 2) // Build events for connector 1 - const event1Start = OCPP20ServiceUtils.buildTransactionEvent( + const event1Start = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, 1, transactionId1 ) - const event1Update = OCPP20ServiceUtils.buildTransactionEvent( + const event1Update = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.MeterValuePeriodic, @@ -2718,14 +2254,14 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { ) // Build events for connector 2 - const event2Start = OCPP20ServiceUtils.buildTransactionEvent( + const event2Start = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.Authorized, 2, transactionId2 ) - const event2Update = OCPP20ServiceUtils.buildTransactionEvent( + const event2Update = buildTransactionEvent( mockStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.MeterValuePeriodic, diff --git a/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts b/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts index 61625ea0..bdd112a5 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts @@ -16,10 +16,7 @@ import type { OCPP20RequestCommand, OCSPRequestDataType, } from '../../../../src/types/index.js' -import type { - OCPP20IdTokenType, - OCPP20TransactionContext, -} from '../../../../src/types/ocpp/2.0/Transaction.js' +import type { OCPP20IdTokenType } from '../../../../src/types/ocpp/2.0/Transaction.js' import { OCPP20RequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20RequestService.js' import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js' @@ -30,7 +27,6 @@ import { OCPP20RequiredVariableName, OCPPVersion, } from '../../../../src/types/index.js' -import { OCPP20IncomingRequestCommand } from '../../../../src/types/ocpp/2.0/Requests.js' import { OCPP20IdTokenEnumType } from '../../../../src/types/ocpp/2.0/Transaction.js' import { Constants } from '../../../../src/utils/index.js' import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js' @@ -448,200 +444,6 @@ export const IdTokenFixtures = { }), } as const -/** - * Pre-built TransactionContext factories for common flow patterns. - * Use these to create standardized contexts for different transaction flows. - */ -export const TransactionContextFixtures = { - // ===== Local Authorization Contexts ===== - - /** - * Abnormal condition (with optional condition type). - * @param condition - The abnormal condition type. - * @returns An OCPP20TransactionContext for abnormal conditions. - */ - abnormalCondition: (condition = 'OverCurrent'): OCPP20TransactionContext => ({ - abnormalCondition: condition, - source: 'abnormal_condition', - }), - - /** - * Cable plugged in (E02 cable-first start). - * @returns An OCPP20TransactionContext for cable plugged in. - */ - cablePluggedIn: (): OCPP20TransactionContext => ({ - cableState: 'plugged_in', - source: 'cable_action', - }), - - /** - * Deauthorization (token revoked or invalid). - * @returns An OCPP20TransactionContext for deauthorization. - */ - deauthorized: (): OCPP20TransactionContext => ({ - authorizationMethod: 'idToken', - isDeauthorized: true, - source: 'local_authorization', - }), - - // ===== Cable Action Contexts (E02 flow) ===== - - /** - * Energy limit reached. - * @returns An OCPP20TransactionContext for energy limit reached. - */ - energyLimitReached: (): OCPP20TransactionContext => ({ - source: 'energy_limit', - }), - - /** - * EV communication lost. - * @returns An OCPP20TransactionContext for EV communication lost. - */ - evCommunicationLost: (): OCPP20TransactionContext => ({ - source: 'system_event', - systemEvent: 'ev_communication_lost', - }), - - /** - * EV connect timeout. - * @returns An OCPP20TransactionContext for EV connect timeout. - */ - evConnectTimeout: (): OCPP20TransactionContext => ({ - source: 'system_event', - systemEvent: 'ev_connect_timeout', - }), - - // ===== Remote Command Contexts ===== - - /** - * Cable unplugged / EV departed. - * @returns An OCPP20TransactionContext for EV departure. - */ - evDeparted: (): OCPP20TransactionContext => ({ - cableState: 'unplugged', - source: 'cable_action', - }), - - /** - * EV detected after cable connection. - * @returns An OCPP20TransactionContext for EV detection. - */ - evDetected: (): OCPP20TransactionContext => ({ - cableState: 'detected', - source: 'cable_action', - }), - - /** - * IdToken-first authorization (E03 flow start). - * @param authorizationMethod - The authorization method used. - * @returns An OCPP20TransactionContext for IdToken authorization. - */ - idTokenAuthorized: ( - authorizationMethod: 'groupIdToken' | 'idToken' = 'idToken' - ): OCPP20TransactionContext => ({ - authorizationMethod, - source: 'local_authorization', - }), - - /** - * Clock-aligned meter value. - * @returns An OCPP20TransactionContext for clock-aligned meter values. - */ - meterValueClock: (): OCPP20TransactionContext => ({ - isPeriodicMeterValue: false, - source: 'meter_value', - }), - - /** - * Periodic meter value (sampled interval). - * @returns An OCPP20TransactionContext for periodic meter values. - */ - meterValuePeriodic: (): OCPP20TransactionContext => ({ - isPeriodicMeterValue: true, - source: 'meter_value', - }), - - // ===== Meter Value Contexts ===== - - /** - * Remote start transaction request. - * @returns An OCPP20TransactionContext for remote start. - */ - remoteStart: (): OCPP20TransactionContext => ({ - command: OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION, - source: 'remote_command', - }), - - /** - * Remote stop transaction request. - * @returns An OCPP20TransactionContext for remote stop. - */ - remoteStop: (): OCPP20TransactionContext => ({ - command: OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION, - source: 'remote_command', - }), - - /** - * Reset command. - * @returns An OCPP20TransactionContext for reset. - */ - reset: (): OCPP20TransactionContext => ({ - command: OCPP20IncomingRequestCommand.RESET, - source: 'remote_command', - }), - - // ===== System Event Contexts ===== - - /** - * Signed data received. - * @returns An OCPP20TransactionContext for signed data. - */ - signedData: (): OCPP20TransactionContext => ({ - isSignedDataReceived: true, - source: 'meter_value', - }), - - /** - * Stop authorized by local token presentation. - * @returns An OCPP20TransactionContext for stop authorization. - */ - stopAuthorized: (): OCPP20TransactionContext => ({ - authorizationMethod: 'stopAuthorized', - source: 'local_authorization', - }), - - // ===== Limit Contexts ===== - - /** - * Time limit reached. - * @returns An OCPP20TransactionContext for time limit. - */ - timeLimitReached: (): OCPP20TransactionContext => ({ - source: 'time_limit', - }), - - /** - * Trigger message command. - * @returns An OCPP20TransactionContext for trigger message. - */ - triggerMessage: (): OCPP20TransactionContext => ({ - command: OCPP20IncomingRequestCommand.TRIGGER_MESSAGE, - source: 'remote_command', - }), - - // ===== Abnormal Condition Contexts ===== - - /** - * Unlock connector command. - * @returns An OCPP20TransactionContext for unlock connector. - */ - unlockConnector: (): OCPP20TransactionContext => ({ - command: OCPP20IncomingRequestCommand.UNLOCK_CONNECTOR, - source: 'remote_command', - }), -} as const - /** * Pre-built mock station fixtures for Reset command testing. * These factories create properly configured MockChargingStation instances @@ -706,8 +508,6 @@ export interface TransactionFlowPattern { expectedStartTrigger: string /** Whether to include idToken in Started event */ includeIdToken: boolean - /** Context to use for Started event (determines initial trigger reason) */ - startContext: OCPP20TransactionContext } /** @@ -719,19 +519,16 @@ export const TransactionFlowPatterns: TransactionFlowPattern[] = [ description: 'E02 Cable-First: CablePluggedIn → Charging → EVDeparted', expectedStartTrigger: 'CablePluggedIn', includeIdToken: false, - startContext: TransactionContextFixtures.cablePluggedIn(), }, { description: 'E03 IdToken-First: Authorized → Cable → Charging → StopAuthorized', expectedStartTrigger: 'Authorized', includeIdToken: true, - startContext: TransactionContextFixtures.idTokenAuthorized(), }, { description: 'Remote Start: RemoteStart → Charging → RemoteStop', expectedStartTrigger: 'RemoteStart', includeIdToken: false, - startContext: TransactionContextFixtures.remoteStart(), }, ] as const -- 2.53.0