From 044bd64305aee79aecbcc8a41ab892717680ea97 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Tue, 24 Mar 2026 20:40:46 +0100 Subject: [PATCH] fix: add OCPP 2.0 DataTransfer outgoing support and B03 boot retry in Rejected state Add DATA_TRANSFER to OCPP20RequestCommand enum, buildRequestPayload passthrough, schema registration, and response handler (P02.FR.02). Allow BootNotification retry when station is in Rejected state by adding inRejectedState() to the internalSendMessage gate (B03.FR.06). Fix mock server: use OCPPCommCtrlr component for HeartbeatInterval in SetVariables, add customerIdentifier in CustomerInformation. Add unit tests for promiseWithTimeout and DataTransfer outgoing. --- .../ocpp/2.0/OCPP20RequestService.ts | 1 + .../ocpp/2.0/OCPP20ResponseService.ts | 14 +++ .../ocpp/2.0/OCPP20ServiceUtils.ts | 1 + .../ocpp/OCPPRequestService.ts | 4 +- src/types/ocpp/2.0/Requests.ts | 1 + .../OCPP20RequestService-DataTransfer.test.ts | 111 ++++++++++++++++++ tests/ocpp-server/server.py | 3 +- tests/utils/Utils.test.ts | 54 +++++++++ 8 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 tests/charging-station/ocpp/2.0/OCPP20RequestService-DataTransfer.test.ts diff --git a/src/charging-station/ocpp/2.0/OCPP20RequestService.ts b/src/charging-station/ocpp/2.0/OCPP20RequestService.ts index 3e04d7c7..fad5b547 100644 --- a/src/charging-station/ocpp/2.0/OCPP20RequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20RequestService.ts @@ -152,6 +152,7 @@ export class OCPP20RequestService extends OCPPRequestService { switch (commandName) { case OCPP20RequestCommand.AUTHORIZE: case OCPP20RequestCommand.BOOT_NOTIFICATION: + case OCPP20RequestCommand.DATA_TRANSFER: case OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION: case OCPP20RequestCommand.GET_15118_EV_CERTIFICATE: case OCPP20RequestCommand.GET_CERTIFICATE_STATUS: diff --git a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts index 23f4e6e4..57895e2c 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts @@ -8,6 +8,7 @@ import { OCPP20AuthorizationStatusEnumType, type OCPP20AuthorizeResponse, type OCPP20BootNotificationResponse, + type OCPP20DataTransferResponse, type OCPP20FirmwareStatusNotificationResponse, type OCPP20Get15118EVCertificateResponse, type OCPP20GetCertificateStatusResponse, @@ -103,6 +104,10 @@ export class OCPP20ResponseService extends OCPPResponseService { OCPP20RequestCommand.BOOT_NOTIFICATION, this.handleResponseBootNotification.bind(this) as ResponseHandler, ], + [ + OCPP20RequestCommand.DATA_TRANSFER, + this.handleResponseDataTransfer.bind(this) as ResponseHandler, + ], [ OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION, this.handleResponseFirmwareStatusNotification.bind(this) as ResponseHandler, @@ -231,6 +236,15 @@ export class OCPP20ResponseService extends OCPPResponseService { } } + private handleResponseDataTransfer ( + chargingStation: ChargingStation, + payload: OCPP20DataTransferResponse + ): void { + logger.debug( + `${chargingStation.logPrefix()} ${moduleName}.handleResponseDataTransfer: DataTransfer response received, status: ${payload.status}` + ) + } + private handleResponseFirmwareStatusNotification ( chargingStation: ChargingStation, payload: OCPP20FirmwareStatusNotificationResponse diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index bb1f69ff..8a543958 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -77,6 +77,7 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { private static readonly outgoingRequestSchemaNames: readonly [OCPP20RequestCommand, string][] = [ [OCPP20RequestCommand.AUTHORIZE, 'Authorize'], [OCPP20RequestCommand.BOOT_NOTIFICATION, 'BootNotification'], + [OCPP20RequestCommand.DATA_TRANSFER, 'DataTransfer'], [OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION, 'FirmwareStatusNotification'], [OCPP20RequestCommand.GET_15118_EV_CERTIFICATE, 'Get15118EVCertificate'], [OCPP20RequestCommand.GET_CERTIFICATE_STATUS, 'GetCertificateStatus'], diff --git a/src/charging-station/ocpp/OCPPRequestService.ts b/src/charging-station/ocpp/OCPPRequestService.ts index a365828c..f433945a 100644 --- a/src/charging-station/ocpp/OCPPRequestService.ts +++ b/src/charging-station/ocpp/OCPPRequestService.ts @@ -331,7 +331,9 @@ export abstract class OCPPRequestService { ...params, } if ( - ((chargingStation.inUnknownState() || chargingStation.inPendingState()) && + ((chargingStation.inUnknownState() || + chargingStation.inPendingState() || + chargingStation.inRejectedState()) && commandName === RequestCommand.BOOT_NOTIFICATION) || (chargingStation.stationInfo?.ocppStrictCompliance === false && (chargingStation.inUnknownState() || chargingStation.inPendingState())) || diff --git a/src/types/ocpp/2.0/Requests.ts b/src/types/ocpp/2.0/Requests.ts index 13743835..4476ec27 100644 --- a/src/types/ocpp/2.0/Requests.ts +++ b/src/types/ocpp/2.0/Requests.ts @@ -60,6 +60,7 @@ export enum OCPP20IncomingRequestCommand { export enum OCPP20RequestCommand { AUTHORIZE = 'Authorize', BOOT_NOTIFICATION = 'BootNotification', + DATA_TRANSFER = 'DataTransfer', FIRMWARE_STATUS_NOTIFICATION = 'FirmwareStatusNotification', GET_15118_EV_CERTIFICATE = 'Get15118EVCertificate', GET_CERTIFICATE_STATUS = 'GetCertificateStatus', diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-DataTransfer.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-DataTransfer.test.ts new file mode 100644 index 00000000..be6c77c1 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20RequestService-DataTransfer.test.ts @@ -0,0 +1,111 @@ +/** + * @file Tests for OCPP20RequestService DataTransfer + * @description Unit tests for OCPP 2.0.1 DataTransfer outgoing command (P02) + */ + +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it } from 'node:test' + +import type { ChargingStation } from '../../../../src/charging-station/index.js' + +import { + createTestableRequestService, + type SendMessageMock, + type TestableOCPP20RequestService, +} from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js' +import { + type OCPP20DataTransferRequest, + type OCPP20DataTransferResponse, + OCPP20RequestCommand, + OCPPVersion, +} from '../../../../src/types/index.js' +import { Constants } from '../../../../src/utils/index.js' +import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' +import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js' +import { createMockChargingStation } from '../../ChargingStationTestUtils.js' + +await describe('P02 - DataTransfer', async () => { + let station: ChargingStation + let sendMessageMock: SendMessageMock + let service: TestableOCPP20RequestService + + beforeEach(() => { + const { station: newStation } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 1, + evseConfiguration: { evsesCount: 1 }, + heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL, + stationInfo: { + ocppStrictCompliance: false, + ocppVersion: OCPPVersion.VERSION_201, + }, + websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL, + }) + station = newStation + + const testable = createTestableRequestService({ + sendMessageResponse: {}, + }) + sendMessageMock = testable.sendMessageMock + service = testable.service + }) + + afterEach(() => { + standardCleanup() + }) + + await it('should send DataTransfer with vendorId, messageId, and data', async () => { + const payload: OCPP20DataTransferRequest = { + data: 'test-payload-data', + messageId: 'TestMessage001', + vendorId: 'com.example.vendor', + } + + await service.requestHandler(station, OCPP20RequestCommand.DATA_TRANSFER, payload) + + assert.strictEqual(sendMessageMock.mock.calls.length, 1) + + const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20DataTransferRequest + assert.strictEqual(sentPayload.vendorId, 'com.example.vendor') + assert.strictEqual(sentPayload.messageId, 'TestMessage001') + assert.strictEqual(sentPayload.data, 'test-payload-data') + + const commandName = sendMessageMock.mock.calls[0].arguments[3] + assert.strictEqual(commandName, OCPP20RequestCommand.DATA_TRANSFER) + }) + + await it('should send DataTransfer with only required vendorId field', async () => { + const payload: OCPP20DataTransferRequest = { + vendorId: 'com.example.minimal', + } + + await service.requestHandler(station, OCPP20RequestCommand.DATA_TRANSFER, payload) + + assert.strictEqual(sendMessageMock.mock.calls.length, 1) + + const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20DataTransferRequest + assert.strictEqual(sentPayload.vendorId, 'com.example.minimal') + assert.strictEqual(sentPayload.messageId, undefined) + assert.strictEqual(sentPayload.data, undefined) + }) + + await it('should pass through the payload as-is for DataTransfer command', async () => { + const payload: OCPP20DataTransferRequest = { + data: { nested: { key: 'value' }, numbers: [1, 2, 3] }, + messageId: 'ComplexData', + vendorId: 'com.example.complex', + } + + await service.requestHandler(station, OCPP20RequestCommand.DATA_TRANSFER, payload) + + assert.strictEqual(sendMessageMock.mock.calls.length, 1) + + const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20DataTransferRequest + assert.deepStrictEqual(sentPayload.data, { + nested: { key: 'value' }, + numbers: [1, 2, 3], + }) + assert.strictEqual(sentPayload.vendorId, 'com.example.complex') + assert.strictEqual(sentPayload.messageId, 'ComplexData') + }) +}) diff --git a/tests/ocpp-server/server.py b/tests/ocpp-server/server.py index 2103fa62..13ceb4d6 100644 --- a/tests/ocpp-server/server.py +++ b/tests/ocpp-server/server.py @@ -350,7 +350,7 @@ class ChargePoint(ocpp.v201.ChargePoint): request = ocpp.v201.call.SetVariables( set_variable_data=[ { - "component": {"name": "ChargingStation"}, + "component": {"name": "OCPPCommCtrlr"}, "variable": {"name": "HeartbeatInterval"}, "attribute_value": "30", } @@ -431,6 +431,7 @@ class ChargePoint(ocpp.v201.ChargePoint): request_id=_random_request_id(), report=True, clear=False, + customer_identifier="test_customer_001", ) await self._call_and_log( request, diff --git a/tests/utils/Utils.test.ts b/tests/utils/Utils.test.ts index 056af9f9..767b86fd 100644 --- a/tests/utils/Utils.test.ts +++ b/tests/utils/Utils.test.ts @@ -46,6 +46,7 @@ import { logPrefix, mergeDeepRight, once, + promiseWithTimeout, queueMicrotaskErrorThrowing, roundTo, secureRandom, @@ -826,4 +827,57 @@ await describe('Utils', async () => { assert.strictEqual(result, 'ABCDEFGH...') }) }) + + await describe('promiseWithTimeout', async () => { + await it('should resolve with the promise value when it settles before timeout', async () => { + const result = await promiseWithTimeout(Promise.resolve(42), 1000, 'Timeout') + assert.strictEqual(result, 42) + }) + + await it('should reject with timeout Error when promise exceeds timeout', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + const timeoutError = new Error('Operation timed out') + const racePromise = promiseWithTimeout( + // eslint-disable-next-line @typescript-eslint/no-empty-function + new Promise(() => {}), + 500, + timeoutError + ) + t.mock.timers.tick(500) + await assert.rejects(racePromise, (error: Error) => { + assert.strictEqual(error, timeoutError) + return true + }) + }) + }) + + await it('should convert string timeoutError to Error on timeout', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + const racePromise = promiseWithTimeout( + // eslint-disable-next-line @typescript-eslint/no-empty-function + new Promise(() => {}), + 500, + 'timed out' + ) + t.mock.timers.tick(500) + await assert.rejects(racePromise, (error: Error) => { + assert.ok(error instanceof Error) + assert.strictEqual(error.message, 'timed out') + return true + }) + }) + }) + + await it('should preserve original rejection when promise rejects before timeout', async () => { + const originalError = new TypeError('Custom typed error') + await assert.rejects( + promiseWithTimeout(Promise.reject(originalError), 10000, 'Should not see this'), + (error: Error) => { + assert.strictEqual(error, originalError) + assert.ok(error instanceof TypeError) + return true + } + ) + }) + }) }) -- 2.43.0