]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix: add OCPP 2.0 DataTransfer outgoing support and B03 boot retry in Rejected state
authorJérôme Benoit <jerome.benoit@sap.com>
Tue, 24 Mar 2026 19:40:46 +0000 (20:40 +0100)
committerJérôme Benoit <jerome.benoit@sap.com>
Tue, 24 Mar 2026 19:40:46 +0000 (20:40 +0100)
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.

src/charging-station/ocpp/2.0/OCPP20RequestService.ts
src/charging-station/ocpp/2.0/OCPP20ResponseService.ts
src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts
src/charging-station/ocpp/OCPPRequestService.ts
src/types/ocpp/2.0/Requests.ts
tests/charging-station/ocpp/2.0/OCPP20RequestService-DataTransfer.test.ts [new file with mode: 0644]
tests/ocpp-server/server.py
tests/utils/Utils.test.ts

index 3e04d7c75a42b0a54c6f6d8007480a2f46429366..fad5b54738118755aa4673b7070572939df91288 100644 (file)
@@ -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:
index 23f4e6e4ca6812ce815318645e4663d98190d91d..57895e2cb17460d770b4a7ca5811e819fde72d13 100644 (file)
@@ -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
index bb1f69ffc880693706d32c19837604c239bb3f15..8a543958833b1300e3299b853916e499232e2f8e 100644 (file)
@@ -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'],
index a365828cf4e68025e39fdd603dae40fca9ef9c9a..f433945aeb9b92adbe5835fdec7557676e1f9ae6 100644 (file)
@@ -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())) ||
index 13743835003b7194ac63f3360907034ea1f45755..4476ec271a90924dfc43bf034a8f464a2408e854 100644 (file)
@@ -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 (file)
index 0000000..be6c77c
--- /dev/null
@@ -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<OCPP20DataTransferResponse>({
+      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')
+  })
+})
index 2103fa62bf79ff3e8a1d48e478a873627ef044a2..13ceb4d6b625355d9ab732c8dcc6973ffdb493de 100644 (file)
@@ -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,
index 056af9f99494c26ebc215c61681f42b906f712cd..767b86fd2cfe142709124a31a1321e3017bccf19 100644 (file)
@@ -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<never>(() => {}),
+          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<never>(() => {}),
+          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
+        }
+      )
+    })
+  })
 })