]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ocpp16): ocpp 1.6 conformance audit — 8 findings remediated
authorJérôme Benoit <jerome.benoit@sap.com>
Sun, 15 Mar 2026 18:54:33 +0000 (19:54 +0100)
committerJérôme Benoit <jerome.benoit@sap.com>
Sun, 15 Mar 2026 18:54:33 +0000 (19:54 +0100)
Fixes validated against OCPP 1.6 Edition 2 spec and OCA JSON schemas:

- DT-01: DataTransfer returns UnknownMessageId when vendorId matches
  but messageId is provided (spec §4.3)
- CCP-01: ClearChargingProfile applies id/stackLevel/purpose filters
  when connectorId is specified instead of clearing all profiles (spec §5.5)
- TM-01/02/03: TriggerMessage handles DiagnosticsStatusNotification,
  FirmwareStatusNotification and MeterValues triggers (spec §5.17)
- GAP-03: ChangeConfiguration rejects non-integer and negative values
  for known integer config keys (spec §5.3)
- GAP-11: Reset distinguishes Hard/Soft — hard reset skips graceful
  transaction stopping (spec §5.14)
- GCS-02: GetCompositeSchedule implements connectorId=0 to report
  total grid consumption across all connectors (spec §5.7)

src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts
src/charging-station/ocpp/OCPPConstants.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-SimpleHandlers.test.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-TriggerMessage.test.ts

index ab39a81f6668a829901af41581a197c0b7e16529..39dace1b34feb56bc7157f9fa273ec41a1ef0da8 100644 (file)
@@ -71,6 +71,9 @@ import {
   type OCPP16HeartbeatResponse,
   OCPP16IncomingRequestCommand,
   OCPP16MessageTrigger,
+  type OCPP16MeterValue,
+  type OCPP16MeterValuesRequest,
+  type OCPP16MeterValuesResponse,
   OCPP16RequestCommand,
   type OCPP16ReserveNowRequest,
   type OCPP16ReserveNowResponse,
@@ -112,7 +115,7 @@ import {
 } from '../../../utils/index.js'
 import { OCPPConstants } from '../OCPPConstants.js'
 import { OCPPIncomingRequestService } from '../OCPPIncomingRequestService.js'
-import { OCPPServiceUtils } from '../OCPPServiceUtils.js'
+import { buildMeterValue, OCPPServiceUtils } from '../OCPPServiceUtils.js'
 import { OCPP16Constants } from './OCPP16Constants.js'
 import { OCPP16ServiceUtils } from './OCPP16ServiceUtils.js'
 
@@ -352,6 +355,40 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
               >(chargingStation, OCPP16RequestCommand.BOOT_NOTIFICATION, chargingStation.bootNotificationRequest as OCPP16BootNotificationRequest, { skipBufferingOnError: true, triggerMessage: true })
               .catch(errorHandler)
             break
+          case OCPP16MessageTrigger.DiagnosticsStatusNotification:
+            chargingStation.ocppRequestService
+              .requestHandler<
+                OCPP16DiagnosticsStatusNotificationRequest,
+                OCPP16DiagnosticsStatusNotificationResponse
+              >(
+                chargingStation,
+                OCPP16RequestCommand.DIAGNOSTICS_STATUS_NOTIFICATION,
+                {
+                  status: OCPP16DiagnosticsStatus.Idle,
+                },
+                {
+                  triggerMessage: true,
+                }
+              )
+              .catch(errorHandler)
+            break
+          case OCPP16MessageTrigger.FirmwareStatusNotification:
+            chargingStation.ocppRequestService
+              .requestHandler<
+                OCPP16FirmwareStatusNotificationRequest,
+                OCPP16FirmwareStatusNotificationResponse
+              >(
+                chargingStation,
+                OCPP16RequestCommand.FIRMWARE_STATUS_NOTIFICATION,
+                {
+                  status: chargingStation.stationInfo?.firmwareStatus ?? OCPP16FirmwareStatus.Idle,
+                },
+                {
+                  triggerMessage: true,
+                }
+              )
+              .catch(errorHandler)
+            break
           case OCPP16MessageTrigger.Heartbeat:
             chargingStation.ocppRequestService
               .requestHandler<OCPP16HeartbeatRequest, OCPP16HeartbeatResponse>(
@@ -364,6 +401,64 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
               )
               .catch(errorHandler)
             break
+          case OCPP16MessageTrigger.MeterValues:
+            if (connectorId != null) {
+              const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+              if (
+                connectorStatus?.transactionStarted === true &&
+                connectorStatus.transactionId != null
+              ) {
+                chargingStation.ocppRequestService
+                  .requestHandler<OCPP16MeterValuesRequest, OCPP16MeterValuesResponse>(
+                    chargingStation,
+                    OCPP16RequestCommand.METER_VALUES,
+                    {
+                      connectorId,
+                      meterValue: [
+                        buildMeterValue(
+                          chargingStation,
+                          connectorId,
+                          convertToInt(connectorStatus.transactionId),
+                          0
+                        ) as OCPP16MeterValue,
+                      ],
+                      transactionId: convertToInt(connectorStatus.transactionId),
+                    },
+                    {
+                      triggerMessage: true,
+                    }
+                  )
+                  .catch(errorHandler)
+              }
+            } else {
+              for (let id = 1; id <= chargingStation.getNumberOfConnectors(); id++) {
+                const cs = chargingStation.getConnectorStatus(id)
+                if (cs?.transactionStarted === true && cs.transactionId != null) {
+                  chargingStation.ocppRequestService
+                    .requestHandler<OCPP16MeterValuesRequest, OCPP16MeterValuesResponse>(
+                      chargingStation,
+                      OCPP16RequestCommand.METER_VALUES,
+                      {
+                        connectorId: id,
+                        meterValue: [
+                          buildMeterValue(
+                            chargingStation,
+                            id,
+                            convertToInt(cs.transactionId),
+                            0
+                          ) as OCPP16MeterValue,
+                        ],
+                        transactionId: convertToInt(cs.transactionId),
+                      },
+                      {
+                        triggerMessage: true,
+                      }
+                    )
+                    .catch(errorHandler)
+                }
+              }
+            }
+            break
           case OCPP16MessageTrigger.StatusNotification:
             if (connectorId != null) {
               chargingStation.ocppRequestService
@@ -633,6 +728,21 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
     if (keyToChange?.readonly === true) {
       return OCPP16Constants.OCPP_CONFIGURATION_RESPONSE_REJECTED
     } else if (keyToChange?.readonly === false) {
+      const integerKeys = new Set([
+        OCPP16StandardParametersKey.ConnectionTimeOut,
+        OCPP16StandardParametersKey.HeartBeatInterval,
+        OCPP16StandardParametersKey.HeartbeatInterval,
+        OCPP16StandardParametersKey.MeterValueSampleInterval,
+        OCPP16StandardParametersKey.TransactionMessageAttempts,
+        OCPP16StandardParametersKey.TransactionMessageRetryInterval,
+        OCPP16StandardParametersKey.WebSocketPingInterval,
+      ])
+      if (integerKeys.has(keyToChange.key as OCPP16StandardParametersKey)) {
+        const numValue = convertToInt(value)
+        if (isNaN(numValue) || numValue < 0) {
+          return OCPP16Constants.OCPP_CONFIGURATION_RESPONSE_REJECTED
+        }
+      }
       let valueChanged = false
       if (keyToChange.value !== value) {
         setConfigurationKeyValue(chargingStation, key, value, true)
@@ -731,11 +841,25 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
       }
       const connectorStatus = chargingStation.getConnectorStatus(connectorId)
       if (isNotEmptyArray(connectorStatus?.chargingProfiles)) {
-        connectorStatus.chargingProfiles = []
-        logger.debug(
-          `${chargingStation.logPrefix()} ${moduleName}.handleRequestClearChargingProfile: Charging profile(s) cleared on connector id ${connectorId.toString()}`
+        const { chargingProfilePurpose, id, stackLevel } = commandPayload
+        if (id == null && chargingProfilePurpose == null && stackLevel == null) {
+          connectorStatus.chargingProfiles = []
+          logger.debug(
+            `${chargingStation.logPrefix()} ${moduleName}.handleRequestClearChargingProfile: All charging profile(s) cleared on connector id ${connectorId.toString()}`
+          )
+          return OCPP16Constants.OCPP_CLEAR_CHARGING_PROFILE_RESPONSE_ACCEPTED
+        }
+        const clearedCP = OCPP16ServiceUtils.clearChargingProfiles(
+          chargingStation,
+          commandPayload,
+          connectorStatus.chargingProfiles as OCPP16ChargingProfile[]
         )
-        return OCPP16Constants.OCPP_CLEAR_CHARGING_PROFILE_RESPONSE_ACCEPTED
+        if (clearedCP) {
+          logger.debug(
+            `${chargingStation.logPrefix()} ${moduleName}.handleRequestClearChargingProfile: Matching charging profile(s) cleared on connector id ${connectorId.toString()}`
+          )
+          return OCPP16Constants.OCPP_CLEAR_CHARGING_PROFILE_RESPONSE_ACCEPTED
+        }
       }
     } else {
       let clearedCP = false
@@ -775,12 +899,15 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
     chargingStation: ChargingStation,
     commandPayload: OCPP16DataTransferRequest
   ): OCPP16DataTransferResponse {
-    const { vendorId } = commandPayload
+    const { messageId, vendorId } = commandPayload
     try {
-      if (vendorId === chargingStation.stationInfo?.chargePointVendor) {
-        return OCPP16Constants.OCPP_DATA_TRANSFER_RESPONSE_ACCEPTED
+      if (vendorId !== chargingStation.stationInfo?.chargePointVendor) {
+        return OCPP16Constants.OCPP_DATA_TRANSFER_RESPONSE_UNKNOWN_VENDOR_ID
+      }
+      if (messageId != null) {
+        return OCPP16Constants.OCPP_DATA_TRANSFER_RESPONSE_UNKNOWN_MESSAGE_ID
       }
-      return OCPP16Constants.OCPP_DATA_TRANSFER_RESPONSE_UNKNOWN_VENDOR_ID
+      return OCPP16Constants.OCPP_DATA_TRANSFER_RESPONSE_ACCEPTED
     } catch (error) {
       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
       return handleIncomingRequestError<OCPP16DataTransferResponse>(
@@ -813,9 +940,88 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
       return OCPP16Constants.OCPP_RESPONSE_REJECTED
     }
     if (connectorId === 0) {
-      logger.warn(
-        `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetCompositeSchedule: Get composite schedule on connector id ${connectorId.toString()} is not yet supported`
-      )
+      if (chargingRateUnit != null) {
+        logger.warn(
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetCompositeSchedule: Get composite schedule with a specified rate unit is not yet supported, no conversion will be done`
+        )
+      }
+      const allChargingProfiles: OCPP16ChargingProfile[] = []
+      for (let id = 0; id <= chargingStation.getNumberOfConnectors(); id++) {
+        if (chargingStation.hasConnector(id)) {
+          allChargingProfiles.push(
+            ...(getConnectorChargingProfiles(chargingStation, id) as OCPP16ChargingProfile[])
+          )
+        }
+      }
+      if (allChargingProfiles.length === 0) {
+        return OCPP16Constants.OCPP_RESPONSE_REJECTED
+      }
+      const currentDate = new Date()
+      const compositeScheduleInterval: Interval = {
+        end: addSeconds(currentDate, duration),
+        start: currentDate,
+      }
+      let previousCompositeSchedule: OCPP16ChargingSchedule | undefined
+      let compositeSchedule: OCPP16ChargingSchedule | undefined
+      for (const chargingProfile of allChargingProfiles) {
+        if (chargingProfile.chargingSchedule.startSchedule == null) {
+          logger.debug(
+            `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetCompositeSchedule: Charging profile id ${chargingProfile.chargingProfileId.toString()} has no startSchedule defined. Trying to set it to the current date`
+          )
+          chargingProfile.chargingSchedule.startSchedule = currentDate
+        }
+        if (!isDate(chargingProfile.chargingSchedule.startSchedule)) {
+          logger.warn(
+            `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetCompositeSchedule: Charging profile id ${chargingProfile.chargingProfileId.toString()} startSchedule property is not a Date instance. Trying to convert it to a Date instance`
+          )
+          chargingProfile.chargingSchedule.startSchedule = convertToDate(
+            chargingProfile.chargingSchedule.startSchedule
+          )
+        }
+        if (chargingProfile.chargingSchedule.duration == null) {
+          logger.debug(
+            `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetCompositeSchedule: Charging profile id ${chargingProfile.chargingProfileId.toString()} has no duration defined and will be set to the maximum time allowed`
+          )
+          chargingProfile.chargingSchedule.duration = differenceInSeconds(
+            maxTime,
+            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+            chargingProfile.chargingSchedule.startSchedule!
+          )
+        }
+        if (
+          !prepareChargingProfileKind(
+            undefined,
+            chargingProfile,
+            compositeScheduleInterval.start,
+            chargingStation.logPrefix()
+          )
+        ) {
+          continue
+        }
+        if (
+          !canProceedChargingProfile(
+            chargingProfile,
+            compositeScheduleInterval.start,
+            chargingStation.logPrefix()
+          )
+        ) {
+          continue
+        }
+        compositeSchedule = OCPP16ServiceUtils.composeChargingSchedules(
+          previousCompositeSchedule,
+          chargingProfile.chargingSchedule,
+          compositeScheduleInterval
+        )
+        previousCompositeSchedule = compositeSchedule
+      }
+      if (compositeSchedule != null) {
+        return {
+          chargingSchedule: compositeSchedule,
+          connectorId: 0,
+          scheduleStart: compositeSchedule.startSchedule,
+          status: GenericStatus.Accepted,
+        }
+      }
       return OCPP16Constants.OCPP_RESPONSE_REJECTED
     }
     if (chargingRateUnit != null) {
@@ -1273,7 +1479,9 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
     commandPayload: ResetRequest
   ): GenericResponse {
     const { type } = commandPayload
-    chargingStation.reset(`${type}Reset` as OCPP16StopTransactionReason).catch((error: unknown) => {
+    const reason = `${type}Reset` as OCPP16StopTransactionReason
+    const graceful = reason === OCPP16StopTransactionReason.SOFT_RESET
+    chargingStation.reset(reason, graceful).catch((error: unknown) => {
       logger.error(
         `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Reset error:`,
         error
@@ -1385,7 +1593,10 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
     }
     switch (requestedMessage) {
       case OCPP16MessageTrigger.BootNotification:
+      case OCPP16MessageTrigger.DiagnosticsStatusNotification:
+      case OCPP16MessageTrigger.FirmwareStatusNotification:
       case OCPP16MessageTrigger.Heartbeat:
+      case OCPP16MessageTrigger.MeterValues:
       case OCPP16MessageTrigger.StatusNotification:
         return OCPP16Constants.OCPP_TRIGGER_MESSAGE_RESPONSE_ACCEPTED
       default:
index a8b9b3119374560f45a02e0b611f9414fe04ec4d..71914164df305f83caadd030b7e78ee271ccf439 100644 (file)
@@ -68,6 +68,10 @@ export class OCPPConstants {
     status: DataTransferStatus.REJECTED,
   })
 
+  static readonly OCPP_DATA_TRANSFER_RESPONSE_UNKNOWN_MESSAGE_ID = Object.freeze({
+    status: DataTransferStatus.UNKNOWN_MESSAGE_ID,
+  })
+
   static readonly OCPP_DATA_TRANSFER_RESPONSE_UNKNOWN_VENDOR_ID = Object.freeze({
     status: DataTransferStatus.UNKNOWN_VENDOR_ID,
   })
index 3e7e6bb939e3b8623409338ce17c8e118bc664ee..c953f7efca7c8540cae5d9c3307e8af0d01d6828 100644 (file)
@@ -60,7 +60,28 @@ await describe('OCPP16IncomingRequestService — SimpleHandlers', async () => {
       assert.strictEqual(typeof response.status, 'string')
     })
 
-    await it('should return Accepted status for matching vendor', () => {
+    await it('should return Accepted status for matching vendor without messageId', () => {
+      // Arrange
+      const { station, testableService } = context
+      const matchingVendor = 'test-vendor-match'
+      if (station.stationInfo != null) {
+        station.stationInfo.chargePointVendor = matchingVendor
+      }
+      const dataTransferRequest = {
+        data: 'test-data',
+        vendorId: matchingVendor,
+      }
+
+      // Act
+      const response = testableService.handleRequestDataTransfer(station, dataTransferRequest)
+
+      // Assert
+      assert.notStrictEqual(response, undefined)
+      assert.strictEqual(response.status, OCPP16DataTransferStatus.ACCEPTED)
+      assert.strictEqual(typeof response.status, 'string')
+    })
+
+    await it('should return UnknownMessageId for matching vendor with messageId', () => {
       // Arrange
       const { station, testableService } = context
       const matchingVendor = 'test-vendor-match'
@@ -78,7 +99,7 @@ await describe('OCPP16IncomingRequestService — SimpleHandlers', async () => {
 
       // Assert
       assert.notStrictEqual(response, undefined)
-      assert.strictEqual(response.status, 'Accepted')
+      assert.strictEqual(response.status, OCPP16DataTransferStatus.UNKNOWN_MESSAGE_ID)
       assert.strictEqual(typeof response.status, 'string')
     })
   })
index b7a7ae6242c41b46b33a9057fac5d528b0333448..f60a0e6f88bab2c4ad2900478c9f5ab46c531fe6 100644 (file)
@@ -84,7 +84,7 @@ await describe('OCPP16IncomingRequestService — TriggerMessage', async () => {
   })
 
   await describe('MeterValues trigger', async () => {
-    await it('should return NotImplemented for MeterValues trigger', () => {
+    await it('should return Accepted for MeterValues trigger', () => {
       // Arrange
       const { station, testableService } = context
 
@@ -95,12 +95,12 @@ await describe('OCPP16IncomingRequestService — TriggerMessage', async () => {
       })
 
       // Assert
-      assert.strictEqual(response.status, OCPP16TriggerMessageStatus.NOT_IMPLEMENTED)
+      assert.strictEqual(response.status, OCPP16TriggerMessageStatus.ACCEPTED)
     })
   })
 
   await describe('DiagnosticsStatusNotification trigger', async () => {
-    await it('should return NotImplemented for DiagnosticsStatusNotification trigger', () => {
+    await it('should return Accepted for DiagnosticsStatusNotification trigger', () => {
       // Arrange
       const { station, testableService } = context
 
@@ -110,12 +110,12 @@ await describe('OCPP16IncomingRequestService — TriggerMessage', async () => {
       })
 
       // Assert
-      assert.strictEqual(response.status, OCPP16TriggerMessageStatus.NOT_IMPLEMENTED)
+      assert.strictEqual(response.status, OCPP16TriggerMessageStatus.ACCEPTED)
     })
   })
 
   await describe('FirmwareStatusNotification trigger', async () => {
-    await it('should return NotImplemented for FirmwareStatusNotification trigger', () => {
+    await it('should return Accepted for FirmwareStatusNotification trigger', () => {
       // Arrange
       const { station, testableService } = context
 
@@ -125,7 +125,7 @@ await describe('OCPP16IncomingRequestService — TriggerMessage', async () => {
       })
 
       // Assert
-      assert.strictEqual(response.status, OCPP16TriggerMessageStatus.NOT_IMPLEMENTED)
+      assert.strictEqual(response.status, OCPP16TriggerMessageStatus.ACCEPTED)
     })
   })