From dbe034ffae6a2648fc9e41197f2c3e015a196592 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sun, 22 Mar 2026 16:36:32 +0100 Subject: [PATCH] fix(ocpp2): align MeterValues implementation with OCPP 2.0.1 spec (#1744) * fix(ocpp2): use AlignedDataInterval for standalone MeterValues Add getAlignedDataInterval() helper to OCPP20ServiceUtils that reads AlignedDataCtrlr.Interval from the variable registry (default 900s). Replace getTxUpdatedInterval() with getAlignedDataInterval() in the broadcast channel handleMeterValues OCPP 2.0 branch. Standalone MeterValuesRequest is non-transaction data per OCPP 2.0.1 spec and should use the aligned data interval, not the tx-updated interval. * fix(ocpp2): include meter value in TransactionEvent Started Add buildTransactionBeginMeterValues() to OCPP20ServiceUtils following the buildFinalMeterValues() pattern. Builds an OCPP20MeterValue with Transaction.Begin context and Energy.Active.Import.Register measurand. Wire it into both TransactionEvent(Started) call sites: - OCPPServiceUtils.startTransactionOnConnector (ATG/broadcast channel) - OCPP20IncomingRequestService RequestStartTransaction event listener This aligns OCPP 2.0 with the OCPP 1.6 beginEndMeterValues behavior per OCPP 2.0.1 spec SampledDataTxStartedMeasurands requirement. * test(ocpp2): add tests for buildTransactionBeginMeterValues Test Transaction.Begin context, energy register value, default to 0 when undefined, and empty array when energy is negative. * refactor(ocpp2): address PR review comments - Extract buildEnergyMeterValues private helper to eliminate DRY violation between buildTransactionBeginMeterValues and buildFinalMeterValues - Add AlignedDataInterval to OCPP20RequiredVariableName enum replacing raw string literal in getAlignedDataInterval - Clarify zero-energy test name to document that 0 Wh is a valid Transaction.Begin reading - Add meterValue assertions to RequestStartTransaction test verifying Transaction.Begin context and Energy.Active.Import.Register measurand * refactor(ocpp2): extract readVariableAsIntervalMs to eliminate DRY getAlignedDataInterval and getTxUpdatedInterval were structurally identical. Extract the shared variable-reading logic into a private readVariableAsIntervalMs helper that accepts component name, variable name, and default seconds. Both public methods become one-liner delegates. * refactor(ocpp2): extract terminateTransaction and resolveActiveTransaction requestDeauthorizeTransaction and requestStopTransaction shared identical transaction termination logic (build final meter values, send TransactionEvent Ended, stop periodic, reset connector status). Extract resolveActiveTransaction for the shared precondition check and transactionId string resolution, and terminateTransaction for the shared Ended event + cleanup workflow. Both public methods now focus only on their unique behavior. * fix(test): use enum constants instead of string literals in RequestStartTransaction test Replace 'Transaction.Begin' and 'Energy.Active.Import.Register' string literals with OCPP20ReadingContextEnumType.TRANSACTION_BEGIN and OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER to match codebase conventions. * refactor(test): replace string literals with enum constants in OCPP 2.0 tests Replace hardcoded string literals with their corresponding OCPP 2.0.1 enum values across test files for type safety and consistency: - RequestStopTransaction: Transaction.End, Energy.Active.Import.Register - SchemaValidation: Immediate, OnIdle, Heartbeat, BootNotification - enforceMessageLimits: TooManyElements, TooLargeElement - CertificateManager: Accepted, Failed, NotFound - ChangeAvailability: UnknownEvse - GetBaseReport: Accepted (SetVariableStatusEnumType) - CertificateSigned: InternalError - ServiceUtils-TransactionEvent: TransactionEvent command name * fix(test): replace bogus mock return values with correctly typed empty responses requestDeauthorizeTransaction mocks returned { status: 'Accepted' } but OCPP20TransactionEventResponse has no status field. Replace with properly typed empty response objects since the return value is not asserted in these tests. * refactor(ocpp2): align method names with OCPP 2.0.1 spec terminology Rename methods to match OCPP 2.0.1 TransactionEvent terminology: - buildTransactionBeginMeterValues -> buildTransactionStartedMeterValues - buildFinalMeterValues -> buildTransactionEndedMeterValues - beginMeterValues variable -> startedMeterValues - finalMeterValues variable -> endedMeterValues The spec uses Started/Updated/Ended for TransactionEvent types, not Begin/Final. * refactor(test): replace 'Operative' string literal with enum constants Use OCPP20OperationalStatusEnumType.Operative in OCPP 2.0 test files and the cross-version AvailabilityType.Operative in MessageChannelUtils test to match ConnectorStatus.availability typed field. --- .../ChargingStationWorkerBroadcastChannel.ts | 4 +- .../ocpp/2.0/OCPP20IncomingRequestService.ts | 8 +- .../ocpp/2.0/OCPP20ServiceUtils.ts | 265 ++++++++++-------- src/charging-station/ocpp/OCPPServiceUtils.ts | 5 + src/types/ocpp/2.0/Variables.ts | 1 + .../ocpp/2.0/OCPP20CertificateManager.test.ts | 17 +- ...ngRequestService-CertificateSigned.test.ts | 3 +- ...gRequestService-ChangeAvailability.test.ts | 3 +- ...comingRequestService-GetBaseReport.test.ts | 3 +- ...estService-RequestStartTransaction.test.ts | 14 + ...uestService-RequestStopTransaction.test.ts | 9 +- ...20ResponseService-TransactionEvent.test.ts | 18 +- .../ocpp/2.0/OCPP20SchemaValidation.test.ts | 20 +- ...CPP20ServiceUtils-TransactionEvent.test.ts | 70 ++++- ...0ServiceUtils-enforceMessageLimits.test.ts | 13 +- tests/utils/MessageChannelUtils.test.ts | 6 +- 16 files changed, 308 insertions(+), 151 deletions(-) diff --git a/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts b/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts index f90a1c4e..559f9cd7 100644 --- a/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts +++ b/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts @@ -475,7 +475,7 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne this.chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_20 || this.chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_201 ) { - const txUpdatedInterval = OCPP20ServiceUtils.getTxUpdatedInterval(this.chargingStation) + const alignedDataInterval = OCPP20ServiceUtils.getAlignedDataInterval(this.chargingStation) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const evseId = this.chargingStation.getEvseIdByConnectorId(connectorId!) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -494,7 +494,7 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne // eslint-disable-next-line @typescript-eslint/no-non-null-assertion connectorId!, transactionId, - txUpdatedInterval + alignedDataInterval ), ], ...requestPayload, diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index 124f9c4d..e7dfc92c 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -394,13 +394,19 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { if (response.status === RequestStartStopStatusEnumType.Accepted) { const connectorId = chargingStation.getConnectorIdByTransactionId(response.transactionId) if (connectorId != null) { + const connectorStatus = chargingStation.getConnectorStatus(connectorId) + const startedMeterValues = + connectorStatus != null + ? OCPP20ServiceUtils.buildTransactionStartedMeterValues(connectorStatus) + : [] OCPP20ServiceUtils.sendTransactionEvent( chargingStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.RemoteStart, connectorId, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - response.transactionId! + response.transactionId!, + startedMeterValues.length > 0 ? { meterValue: startedMeterValues } : undefined ).catch((error: unknown) => { logger.error( `${chargingStation.logPrefix()} ${moduleName}.constructor: TransactionEvent(Started) error:`, diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index 0333fd53..711de446 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -89,6 +89,13 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { [OCPP20RequestCommand.TRANSACTION_EVENT, 'TransactionEvent'], ] + static buildTransactionStartedMeterValues (connectorStatus: ConnectorStatus): OCPP20MeterValue[] { + return OCPP20ServiceUtils.buildEnergyMeterValues( + connectorStatus, + OCPP20ReadingContextEnumType.TRANSACTION_BEGIN + ) + } + /** * OCPP 2.0 Incoming Request Service validator configurations * @returns Array of validator configuration tuples @@ -234,28 +241,22 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { return currentResults } - /** - * Gets the TxUpdatedInterval configuration value for periodic TransactionEvent(Updated) messages. - * Reads the SampledDataCtrlr.TxUpdatedInterval variable and falls back to - * Constants.DEFAULT_TX_UPDATED_INTERVAL if not configured. - * @param chargingStation - The charging station instance - * @returns The interval in milliseconds - */ + public static getAlignedDataInterval (chargingStation: ChargingStation): number { + return OCPP20ServiceUtils.readVariableAsIntervalMs( + chargingStation, + OCPP20ComponentName.AlignedDataCtrlr, + OCPP20RequiredVariableName.AlignedDataInterval, + 900 + ) + } + public static getTxUpdatedInterval (chargingStation: ChargingStation): number { - const variableManager = OCPP20VariableManager.getInstance() - const results = variableManager.getVariables(chargingStation, [ - { - component: { name: OCPP20ComponentName.SampledDataCtrlr }, - variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval }, - }, - ]) - if (results.length > 0 && results[0].attributeValue != null) { - const intervalSeconds = parseInt(results[0].attributeValue, 10) - if (!isNaN(intervalSeconds) && intervalSeconds > 0) { - return secondsToMilliseconds(intervalSeconds) - } - } - return secondsToMilliseconds(Constants.DEFAULT_TX_UPDATED_INTERVAL) + return OCPP20ServiceUtils.readVariableAsIntervalMs( + chargingStation, + OCPP20ComponentName.SampledDataCtrlr, + OCPP20RequiredVariableName.TxUpdatedInterval, + Constants.DEFAULT_TX_UPDATED_INTERVAL + ) } /** @@ -301,53 +302,31 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { 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, - } - ) + const { connectorStatus, transactionId } = OCPP20ServiceUtils.resolveActiveTransaction( + chargingStation, + connectorId + ) - OCPP20ServiceUtils.stopPeriodicMeterValues(chargingStation, connectorId) - resetConnectorStatus(connectorStatus) - await sendAndSetConnectorStatus(chargingStation, connectorId, ConnectorStatusEnum.Available) + await this.sendTransactionEvent( + chargingStation, + OCPP20TransactionEventEnumType.Updated, + OCPP20TriggerReasonEnumType.Deauthorized, + connectorId, + transactionId, + { + chargingState: OCPP20ChargingStateEnumType.SuspendedEVSE, + evseId, + } + ) - return response - } - throw new OCPPError( - ErrorType.PROPERTY_CONSTRAINT_VIOLATION, - `No active transaction on connector ${connectorId.toString()}` + return this.terminateTransaction( + chargingStation, + connectorId, + connectorStatus, + transactionId, + OCPP20TriggerReasonEnumType.Deauthorized, + OCPP20ReasonEnumType.DeAuthorized, + evseId ) } @@ -358,47 +337,19 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { triggerReason: OCPP20TriggerReasonEnumType = OCPP20TriggerReasonEnumType.RemoteStop, stoppedReason: OCPP20ReasonEnumType = OCPP20ReasonEnumType.Remote ): Promise { - const connectorStatus = chargingStation.getConnectorStatus(connectorId) - if ( - (connectorStatus?.transactionStarted === true || - connectorStatus?.transactionPending === true) && - connectorStatus.transactionId != null - ) { - let transactionId: string - if (typeof connectorStatus.transactionId === 'string') { - transactionId = connectorStatus.transactionId - } else { - transactionId = connectorStatus.transactionId.toString() - logger.warn( - `${chargingStation.logPrefix()} ${moduleName}.requestStopTransaction: Non-string transaction ID ${transactionId} converted to string for OCPP 2.0` - ) - } - - // F03.FR.04: Build final meter values for TransactionEvent(Ended) - const finalMeterValues = this.buildFinalMeterValues(connectorStatus) - - const response = await this.sendTransactionEvent( - chargingStation, - OCPP20TransactionEventEnumType.Ended, - triggerReason, - connectorId, - transactionId, - { - evseId, - meterValue: finalMeterValues.length > 0 ? finalMeterValues : undefined, - stoppedReason, - } - ) - - OCPP20ServiceUtils.stopPeriodicMeterValues(chargingStation, connectorId) - resetConnectorStatus(connectorStatus) - await sendAndSetConnectorStatus(chargingStation, connectorId, ConnectorStatusEnum.Available) + const { connectorStatus, transactionId } = OCPP20ServiceUtils.resolveActiveTransaction( + chargingStation, + connectorId + ) - return response - } - throw new OCPPError( - ErrorType.PROPERTY_CONSTRAINT_VIOLATION, - `No active transaction on connector ${connectorId.toString()}` + return this.terminateTransaction( + chargingStation, + connectorId, + connectorStatus, + transactionId, + triggerReason, + stoppedReason, + evseId ) } @@ -657,14 +608,17 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { } } - private static buildFinalMeterValues (connectorStatus: ConnectorStatus): OCPP20MeterValue[] { - const finalMeterValues: OCPP20MeterValue[] = [] + private static buildEnergyMeterValues ( + connectorStatus: ConnectorStatus, + context: OCPP20ReadingContextEnumType + ): OCPP20MeterValue[] { + const meterValues: OCPP20MeterValue[] = [] const energyValue = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0 if (energyValue >= 0) { - finalMeterValues.push({ + meterValues.push({ sampledValue: [ { - context: OCPP20ReadingContextEnumType.TRANSACTION_END, + context, measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, value: energyValue, }, @@ -672,7 +626,96 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { timestamp: new Date(), }) } - return finalMeterValues + return meterValues + } + + private static buildTransactionEndedMeterValues ( + connectorStatus: ConnectorStatus + ): OCPP20MeterValue[] { + return OCPP20ServiceUtils.buildEnergyMeterValues( + connectorStatus, + OCPP20ReadingContextEnumType.TRANSACTION_END + ) + } + + private static readVariableAsIntervalMs ( + chargingStation: ChargingStation, + componentName: string, + variableName: string, + defaultSeconds: number + ): number { + const variableManager = OCPP20VariableManager.getInstance() + const results = variableManager.getVariables(chargingStation, [ + { + component: { name: componentName }, + variable: { name: variableName }, + }, + ]) + if (results.length > 0 && results[0].attributeValue != null) { + const intervalSeconds = parseInt(results[0].attributeValue, 10) + if (!isNaN(intervalSeconds) && intervalSeconds > 0) { + return secondsToMilliseconds(intervalSeconds) + } + } + return secondsToMilliseconds(defaultSeconds) + } + + private static resolveActiveTransaction ( + chargingStation: ChargingStation, + connectorId: number + ): { connectorStatus: ConnectorStatus; transactionId: string } { + const connectorStatus = chargingStation.getConnectorStatus(connectorId) + if ( + (connectorStatus?.transactionStarted === true || + connectorStatus?.transactionPending === true) && + connectorStatus.transactionId != null + ) { + let transactionId: string + if (typeof connectorStatus.transactionId === 'string') { + transactionId = connectorStatus.transactionId + } else { + transactionId = connectorStatus.transactionId.toString() + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.resolveActiveTransaction: Non-string transaction ID ${transactionId} converted to string for OCPP 2.0` + ) + } + return { connectorStatus, transactionId } + } + throw new OCPPError( + ErrorType.PROPERTY_CONSTRAINT_VIOLATION, + `No active transaction on connector ${connectorId.toString()}` + ) + } + + private static async terminateTransaction ( + chargingStation: ChargingStation, + connectorId: number, + connectorStatus: ConnectorStatus, + transactionId: string, + triggerReason: OCPP20TriggerReasonEnumType, + stoppedReason: OCPP20ReasonEnumType, + evseId?: number + ): Promise { + const endedMeterValues = this.buildTransactionEndedMeterValues(connectorStatus) + + const response = await this.sendTransactionEvent( + chargingStation, + OCPP20TransactionEventEnumType.Ended, + triggerReason, + connectorId, + transactionId, + { + evseId, + meterValue: endedMeterValues.length > 0 ? endedMeterValues : undefined, + stoppedReason, + } + ) + + OCPP20ServiceUtils.stopPeriodicMeterValues(chargingStation, connectorId) + resetConnectorStatus(connectorStatus) + await sendAndSetConnectorStatus(chargingStation, connectorId, ConnectorStatusEnum.Available) + + return response } } export function buildTransactionEvent ( diff --git a/src/charging-station/ocpp/OCPPServiceUtils.ts b/src/charging-station/ocpp/OCPPServiceUtils.ts index 6c4a881d..6ab76a06 100644 --- a/src/charging-station/ocpp/OCPPServiceUtils.ts +++ b/src/charging-station/ocpp/OCPPServiceUtils.ts @@ -436,6 +436,10 @@ export const startTransactionOnConnector = async ( } OCPP20ServiceUtils.resetTransactionSequenceNumber(chargingStation, connectorId) } + const startedMeterValues = + connectorStatus != null + ? OCPP20ServiceUtils.buildTransactionStartedMeterValues(connectorStatus) + : [] const response = await OCPP20ServiceUtils.sendTransactionEvent( chargingStation, OCPP20TransactionEventEnumType.Started, @@ -445,6 +449,7 @@ export const startTransactionOnConnector = async ( { idToken: idTag != null ? { idToken: idTag, type: OCPP20IdTokenEnumType.Central } : undefined, + ...(startedMeterValues.length > 0 && { meterValue: startedMeterValues }), } ) return { diff --git a/src/types/ocpp/2.0/Variables.ts b/src/types/ocpp/2.0/Variables.ts index b57d63c0..619ea2da 100644 --- a/src/types/ocpp/2.0/Variables.ts +++ b/src/types/ocpp/2.0/Variables.ts @@ -38,6 +38,7 @@ export enum OCPP20OptionalVariableName { } export enum OCPP20RequiredVariableName { + AlignedDataInterval = 'Interval', AuthorizeRemoteStart = 'AuthorizeRemoteStart', BytesPerMessage = 'BytesPerMessage', CertificateEntries = 'CertificateEntries', diff --git a/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts b/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts index bee5d608..c0f1fb8d 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, it } from 'node:test' import { OCPP20CertificateManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20CertificateManager.js' import { type CertificateHashDataType, + DeleteCertificateStatusEnumType, HashAlgorithmEnumType, InstallCertificateUseEnumType, } from '../../../../src/types/index.js' @@ -128,7 +129,13 @@ await describe('I02-I04 - ISO15118 Certificate Management', async () => { assert.notStrictEqual(result, undefined) assert.notStrictEqual(result.status, undefined) - assert.ok(['Accepted', 'Failed', 'NotFound'].includes(result.status)) + assert.ok( + [ + DeleteCertificateStatusEnumType.Accepted, + DeleteCertificateStatusEnumType.Failed, + DeleteCertificateStatusEnumType.NotFound, + ].includes(result.status) + ) }) await it('should return NotFound for non-existent certificate', async () => { @@ -142,7 +149,7 @@ await describe('I02-I04 - ISO15118 Certificate Management', async () => { const result = await manager.deleteCertificate(TEST_STATION_HASH_ID, hashData) assert.notStrictEqual(result, undefined) - assert.strictEqual(result.status, 'NotFound') + assert.strictEqual(result.status, DeleteCertificateStatusEnumType.NotFound) }) await it('should handle filesystem errors gracefully', async () => { @@ -156,7 +163,11 @@ await describe('I02-I04 - ISO15118 Certificate Management', async () => { const result = await manager.deleteCertificate('invalid-station-id', hashData) assert.notStrictEqual(result, undefined) - assert.ok(['Failed', 'NotFound'].includes(result.status)) + assert.ok( + [DeleteCertificateStatusEnumType.Failed, DeleteCertificateStatusEnumType.NotFound].includes( + result.status + ) + ) }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts index f2c80964..5dff150a 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts @@ -19,6 +19,7 @@ import { type OCPP20CertificateSignedResponse, OCPP20RequestCommand, OCPPVersion, + ReasonCodeEnumType, } from '../../../../src/types/index.js' import { Constants } from '../../../../src/utils/index.js' import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' @@ -198,7 +199,7 @@ await describe('I04 - CertificateSigned', async () => { assert.notStrictEqual(response, undefined) assert.strictEqual(response.status, GenericStatus.Rejected) assert.notStrictEqual(response.statusInfo, undefined) - assert.strictEqual(response.statusInfo?.reasonCode, 'InternalError') + assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.InternalError) }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ChangeAvailability.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ChangeAvailability.test.ts index 9408511b..2f56cd73 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ChangeAvailability.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ChangeAvailability.test.ts @@ -14,6 +14,7 @@ import { ChangeAvailabilityStatusEnumType, OCPP20OperationalStatusEnumType, OCPPVersion, + ReasonCodeEnumType, } from '../../../../src/types/index.js' import { Constants } from '../../../../src/utils/index.js' import { @@ -116,7 +117,7 @@ await describe('G03 - ChangeAvailability', async () => { assert.strictEqual(response.status, ChangeAvailabilityStatusEnumType.Rejected) assert.notStrictEqual(response.statusInfo, undefined) - assert.strictEqual(response.statusInfo?.reasonCode, 'UnknownEvse') + assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.UnknownEvse) }) await it('should accept when already in requested state (idempotent)', () => { diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts index 29eb13c8..690c68b9 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts @@ -29,6 +29,7 @@ import { OCPPVersion, ReportBaseEnumType, type ReportDataType, + SetVariableStatusEnumType, StandardParametersKey, } from '../../../../src/types/index.js' import { Constants } from '../../../../src/utils/index.js' @@ -292,7 +293,7 @@ await describe('B07 - Get Base Report', async () => { variable: { name: OCPP20RequiredVariableName.TimeSource }, }, ]) - assert.strictEqual(setResult[0].attributeStatus, 'Accepted') + assert.strictEqual(setResult[0].attributeStatus, SetVariableStatusEnumType.Accepted) // Build report; value should be truncated to length 10 const reportData = testableService.buildReportData(station, ReportBaseEnumType.FullInventory) 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 60eb3de0..61cf02ca 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts @@ -24,6 +24,8 @@ import { OCPP20ChargingProfilePurposeEnumType, OCPP20IdTokenEnumType, OCPP20IncomingRequestCommand, + OCPP20MeasurandEnumType, + OCPP20ReadingContextEnumType, OCPP20RequestCommand, OCPP20TransactionEventEnumType, OCPP20TriggerReasonEnumType, @@ -449,6 +451,18 @@ await describe('F01 & F02 - Remote Start Transaction', async () => { ] assert.strictEqual(args[1], OCPP20RequestCommand.TRANSACTION_EVENT) assert.strictEqual(args[2].eventType, OCPP20TransactionEventEnumType.Started) + assert.ok( + Array.isArray(args[2].meterValue) && args[2].meterValue.length > 0, + 'TransactionEvent(Started) should include non-empty meterValue array' + ) + assert.strictEqual( + args[2].meterValue[0].sampledValue[0].context, + OCPP20ReadingContextEnumType.TRANSACTION_BEGIN + ) + assert.strictEqual( + args[2].meterValue[0].sampledValue[0].measurand, + OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER + ) }) await it('should NOT call TransactionEvent when response is Rejected', () => { diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts index 85ff25fa..f4f79bac 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts @@ -23,6 +23,8 @@ import { OCPPAuthServiceFactory } from '../../../../src/charging-station/ocpp/au import { OCPP20IdTokenEnumType, OCPP20IncomingRequestCommand, + OCPP20MeasurandEnumType, + OCPP20ReadingContextEnumType, OCPP20ReasonEnumType, OCPP20RequestCommand, OCPP20TransactionEventEnumType, @@ -425,8 +427,11 @@ await describe('F03 - Remote Stop Transaction', async () => { const sampledValue = meterValue.sampledValue[0] assert.strictEqual(sampledValue.value, 12345.67) - assert.strictEqual(sampledValue.context, 'Transaction.End') - assert.strictEqual(sampledValue.measurand, 'Energy.Active.Import.Register') + assert.strictEqual(sampledValue.context, OCPP20ReadingContextEnumType.TRANSACTION_END) + assert.strictEqual( + sampledValue.measurand, + OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER + ) }) }) }) 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 492fa105..ee3edeca 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts @@ -111,7 +111,7 @@ await describe('D01 - TransactionEvent Response', async () => { const mockDeauthTransaction = mock.method( OCPP20ServiceUtils, 'requestDeauthorizeTransaction', - () => Promise.resolve({ status: 'Accepted' }) + () => Promise.resolve({} as OCPP20TransactionEventResponse) ) const payload: OCPP20TransactionEventResponse = { idTokenInfo: { @@ -132,7 +132,7 @@ await describe('D01 - TransactionEvent Response', async () => { const mockDeauthTransaction = mock.method( OCPP20ServiceUtils, 'requestDeauthorizeTransaction', - () => Promise.resolve({ status: 'Accepted' }) + () => Promise.resolve({} as OCPP20TransactionEventResponse) ) const payload: OCPP20TransactionEventResponse = { idTokenInfo: { @@ -156,7 +156,7 @@ await describe('D01 - TransactionEvent Response', async () => { const mockDeauthTransaction = mock.method( OCPP20ServiceUtils, 'requestDeauthorizeTransaction', - () => Promise.resolve({ status: 'Accepted' }) + () => Promise.resolve({} as OCPP20TransactionEventResponse) ) const payload: OCPP20TransactionEventResponse = { idTokenInfo: { @@ -178,7 +178,7 @@ await describe('D01 - TransactionEvent Response', async () => { const mockDeauthTransaction = mock.method( OCPP20ServiceUtils, 'requestDeauthorizeTransaction', - () => Promise.resolve({ status: 'Accepted' }) + () => Promise.resolve({} as OCPP20TransactionEventResponse) ) const payload: OCPP20TransactionEventResponse = { chargingPriority: 5, @@ -197,7 +197,7 @@ await describe('D01 - TransactionEvent Response', async () => { const mockDeauthTransaction = mock.method( OCPP20ServiceUtils, 'requestDeauthorizeTransaction', - () => Promise.resolve({ status: 'Accepted' }) + () => Promise.resolve({} as OCPP20TransactionEventResponse) ) const payload: OCPP20TransactionEventResponse = {} const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_ID) @@ -214,7 +214,7 @@ await describe('D01 - TransactionEvent Response', async () => { const mockDeauthTransaction = mock.method( OCPP20ServiceUtils, 'requestDeauthorizeTransaction', - () => Promise.resolve({ status: 'Accepted' }) + () => Promise.resolve({} as OCPP20TransactionEventResponse) ) const payload: OCPP20TransactionEventResponse = { idTokenInfo: { @@ -235,7 +235,7 @@ await describe('D01 - TransactionEvent Response', async () => { const mockDeauthTransaction = mock.method( OCPP20ServiceUtils, 'requestDeauthorizeTransaction', - () => Promise.resolve({ status: 'Accepted' }) + () => Promise.resolve({} as OCPP20TransactionEventResponse) ) const payload: OCPP20TransactionEventResponse = { idTokenInfo: { @@ -256,7 +256,7 @@ await describe('D01 - TransactionEvent Response', async () => { const mockDeauthTransaction = mock.method( OCPP20ServiceUtils, 'requestDeauthorizeTransaction', - () => Promise.resolve({ status: 'Accepted' }) + () => Promise.resolve({} as OCPP20TransactionEventResponse) ) const payload: OCPP20TransactionEventResponse = { totalCost: 12.5, @@ -303,7 +303,7 @@ await describe('D01 - TransactionEvent Response', async () => { const mockDeauthTransaction = mock.method( OCPP20ServiceUtils, 'requestDeauthorizeTransaction', - () => Promise.resolve({ status: 'Accepted' }) + () => Promise.resolve({} as OCPP20TransactionEventResponse) ) const payload: OCPP20TransactionEventResponse = { idTokenInfo: { diff --git a/tests/charging-station/ocpp/2.0/OCPP20SchemaValidation.test.ts b/tests/charging-station/ocpp/2.0/OCPP20SchemaValidation.test.ts index f0ca0785..58f6ef23 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20SchemaValidation.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20SchemaValidation.test.ts @@ -20,7 +20,12 @@ import { fileURLToPath } from 'node:url' import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js' import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js' import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js' -import { OCPP20IncomingRequestCommand, OCPP20RequestCommand } from '../../../../src/types/index.js' +import { + MessageTriggerEnumType, + OCPP20IncomingRequestCommand, + OCPP20RequestCommand, + ResetEnumType, +} from '../../../../src/types/index.js' import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' const AjvConstructor = _Ajv.default @@ -198,15 +203,18 @@ await describe('OCPP 2.0 schema validation — negative tests', async () => { await it('should pass validation for valid Reset payloads', () => { const validate = makeValidator('ResetRequest.json') - assert.strictEqual(validate({ type: 'Immediate' }), true) - assert.strictEqual(validate({ type: 'OnIdle' }), true) - assert.strictEqual(validate({ evseId: 1, type: 'OnIdle' }), true) + assert.strictEqual(validate({ type: ResetEnumType.Immediate }), true) + assert.strictEqual(validate({ type: ResetEnumType.OnIdle }), true) + assert.strictEqual(validate({ evseId: 1, type: ResetEnumType.OnIdle }), true) }) await it('should pass validation for valid TriggerMessage payloads', () => { const validate = makeValidator('TriggerMessageRequest.json') - assert.strictEqual(validate({ requestedMessage: 'Heartbeat' }), true) - assert.strictEqual(validate({ requestedMessage: 'BootNotification' }), true) + assert.strictEqual(validate({ requestedMessage: MessageTriggerEnumType.Heartbeat }), true) + assert.strictEqual( + validate({ requestedMessage: MessageTriggerEnumType.BootNotification }), + true + ) }) await describe('schema registration coverage', async () => { 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 44ad5a9d..0e95ffa2 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts @@ -14,6 +14,7 @@ import assert from 'node:assert/strict' import { afterEach, beforeEach, describe, it, mock } from 'node:test' import type { ChargingStation } from '../../../../src/charging-station/index.js' +import type { ConnectorStatus } from '../../../../src/types/ConnectorStatus.js' import type { EmptyObject } from '../../../../src/types/index.js' import { @@ -31,8 +32,10 @@ import { OCPP20IdTokenEnumType, type OCPP20IdTokenType, OCPP20MeasurandEnumType, + OCPP20OperationalStatusEnumType, OCPP20ReadingContextEnumType, OCPP20ReasonEnumType, + OCPP20RequestCommand, OCPP20RequiredVariableName, OCPP20TransactionEventEnumType, type OCPP20TransactionType, @@ -2110,7 +2113,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // Verify the request was sent with correct trigger reason assert.strictEqual(sentRequests.length, 1) - assert.strictEqual(sentRequests[0].command, 'TransactionEvent') + assert.strictEqual(sentRequests[0].command, OCPP20RequestCommand.TRANSACTION_EVENT) assert.strictEqual( sentRequests[0].payload.eventType, OCPP20TransactionEventEnumType.Updated @@ -2458,7 +2461,9 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1) // Assert - const txEvents = mockTracking.sentRequests.filter(r => r.command === 'TransactionEvent') + const txEvents = mockTracking.sentRequests.filter( + r => r.command === (OCPP20RequestCommand.TRANSACTION_EVENT as string) + ) assert.strictEqual(txEvents.length, 2) const updatedEvent = txEvents[0].payload @@ -2488,7 +2493,9 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 2) // Assert - const txEvents = mockTracking.sentRequests.filter(r => r.command === 'TransactionEvent') + const txEvents = mockTracking.sentRequests.filter( + r => r.command === (OCPP20RequestCommand.TRANSACTION_EVENT as string) + ) assert.strictEqual(txEvents.length, 2) const endedPayload = txEvents[1].payload @@ -2608,7 +2615,9 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { await OCPP20ServiceUtils.requestStopTransaction(mockTracking.station, connectorId, 1) // Assert - const txEvents = mockTracking.sentRequests.filter(r => r.command === 'TransactionEvent') + const txEvents = mockTracking.sentRequests.filter( + r => r.command === (OCPP20RequestCommand.TRANSACTION_EVENT as string) + ) assert.strictEqual(txEvents.length, 1) const endedEvent = txEvents[0].payload @@ -2641,7 +2650,9 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { ) // Assert - const txEvents = mockTracking.sentRequests.filter(r => r.command === 'TransactionEvent') + const txEvents = mockTracking.sentRequests.filter( + r => r.command === (OCPP20RequestCommand.TRANSACTION_EVENT as string) + ) assert.strictEqual(txEvents.length, 1) const endedEvent = txEvents[0].payload @@ -2650,4 +2661,53 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { assert.strictEqual(endedEvent.stoppedReason, customStoppedReason) }) }) + + await describe('buildTransactionStartedMeterValues', async () => { + await it('should build meter value with Transaction.Begin context and energy register', () => { + const connectorStatus = { + availability: OCPP20OperationalStatusEnumType.Operative, + MeterValues: [], + transactionEnergyActiveImportRegisterValue: 1234, + } as unknown as ConnectorStatus + + const result = OCPP20ServiceUtils.buildTransactionStartedMeterValues(connectorStatus) + + assert.strictEqual(result.length, 1) + assert.strictEqual(result[0].sampledValue.length, 1) + assert.strictEqual( + result[0].sampledValue[0].context, + OCPP20ReadingContextEnumType.TRANSACTION_BEGIN + ) + assert.strictEqual( + result[0].sampledValue[0].measurand, + OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER + ) + assert.strictEqual(result[0].sampledValue[0].value, 1234) + assert.ok(result[0].timestamp instanceof Date) + }) + + await it('should include meter value with 0 energy when register value is undefined (zero is a valid begin reading)', () => { + const connectorStatus = { + availability: OCPP20OperationalStatusEnumType.Operative, + MeterValues: [], + } as unknown as ConnectorStatus + + const result = OCPP20ServiceUtils.buildTransactionStartedMeterValues(connectorStatus) + + assert.strictEqual(result.length, 1) + assert.strictEqual(result[0].sampledValue[0].value, 0) + }) + + await it('should return empty array when energy register value is negative', () => { + const connectorStatus = { + availability: OCPP20OperationalStatusEnumType.Operative, + MeterValues: [], + transactionEnergyActiveImportRegisterValue: -1, + } as unknown as ConnectorStatus + + const result = OCPP20ServiceUtils.buildTransactionStartedMeterValues(connectorStatus) + + assert.strictEqual(result.length, 0) + }) + }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-enforceMessageLimits.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-enforceMessageLimits.test.ts index 285c8bca..5b91c733 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-enforceMessageLimits.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-enforceMessageLimits.test.ts @@ -7,6 +7,7 @@ import assert from 'node:assert/strict' import { afterEach, describe, it } from 'node:test' import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js' +import { ReasonCodeEnumType } from '../../../../src/types/index.js' import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' interface MockLogger { @@ -170,7 +171,7 @@ await describe('OCPP20ServiceUtils.enforceMessageLimits', async () => { assert.strictEqual(result.rejected, true) assert.strictEqual(result.results.length, 3) for (const r of result.results as RejectedResult[]) { - assert.strictEqual(r.reasonCode, 'TooManyElements') + assert.strictEqual(r.reasonCode, ReasonCodeEnumType.TooManyElements) assert.ok(r.info.includes('ItemsPerMessage limit 2')) } }) @@ -194,7 +195,7 @@ await describe('OCPP20ServiceUtils.enforceMessageLimits', async () => { assert.strictEqual(result.rejected, true) assert.strictEqual(result.results.length, 2) for (const r of result.results as RejectedResult[]) { - assert.strictEqual(r.reasonCode, 'TooManyElements') + assert.strictEqual(r.reasonCode, ReasonCodeEnumType.TooManyElements) } }) @@ -259,7 +260,7 @@ await describe('OCPP20ServiceUtils.enforceMessageLimits', async () => { assert.strictEqual(result.rejected, true) assert.strictEqual(result.results.length, 1) const r = (result.results as RejectedResult[])[0] - assert.strictEqual(r.reasonCode, 'TooLargeElement') + assert.strictEqual(r.reasonCode, ReasonCodeEnumType.TooLargeElement) assert.ok(r.info.includes('BytesPerMessage limit 1')) }) @@ -282,7 +283,7 @@ await describe('OCPP20ServiceUtils.enforceMessageLimits', async () => { assert.strictEqual(result.rejected, true) assert.strictEqual(result.results.length, 2) for (const r of result.results as RejectedResult[]) { - assert.strictEqual(r.reasonCode, 'TooLargeElement') + assert.strictEqual(r.reasonCode, ReasonCodeEnumType.TooLargeElement) } }) @@ -326,7 +327,7 @@ await describe('OCPP20ServiceUtils.enforceMessageLimits', async () => { assert.strictEqual(result.rejected, true) for (const r of result.results as RejectedResult[]) { - assert.strictEqual(r.reasonCode, 'TooManyElements') + assert.strictEqual(r.reasonCode, ReasonCodeEnumType.TooManyElements) } }) }) @@ -377,7 +378,7 @@ await describe('OCPP20ServiceUtils.enforceMessageLimits', async () => { ) assert.strictEqual(capturedReasons.length, 1) - assert.strictEqual(capturedReasons[0].reasonCode, 'TooLargeElement') + assert.strictEqual(capturedReasons[0].reasonCode, ReasonCodeEnumType.TooLargeElement) assert.strictEqual(typeof capturedReasons[0].info, 'string') assert.ok(capturedReasons[0].info.length > 0) }) diff --git a/tests/utils/MessageChannelUtils.test.ts b/tests/utils/MessageChannelUtils.test.ts index 293fec2f..76f0b23c 100644 --- a/tests/utils/MessageChannelUtils.test.ts +++ b/tests/utils/MessageChannelUtils.test.ts @@ -10,7 +10,7 @@ import { afterEach, describe, it } from 'node:test' import type { ChargingStation } from '../../src/charging-station/index.js' import type { Statistics, TimestampedData } from '../../src/types/index.js' -import { ChargingStationWorkerMessageEvents } from '../../src/types/index.js' +import { AvailabilityType, ChargingStationWorkerMessageEvents } from '../../src/types/index.js' import { buildAddedMessage, buildDeletedMessage, @@ -37,14 +37,14 @@ function createMockStationForMessages (): ChargingStation { [ 0, { - availability: 'Operative', + availability: AvailabilityType.Operative, MeterValues: [], }, ], [ 1, { - availability: 'Operative', + availability: AvailabilityType.Operative, MeterValues: [], }, ], -- 2.43.0