]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ocpp2): integrate all 8 missing OCPP 2.0.1 outgoing commands into UIService...
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 13 Mar 2026 15:54:08 +0000 (16:54 +0100)
committerGitHub <noreply@github.com>
Fri, 13 Mar 2026 15:54:08 +0000 (16:54 +0100)
* feat(types): add OCPP 2.0.1 UI procedure and broadcast channel enums

- Add 8 new ProcedureName entries (get15118EVCertificate, getCertificateStatus, logStatusNotification, notifyCustomerInformation, notifyReport, securityEventNotification, signCertificate, transactionEvent)
- Mirror same 8 entries in BroadcastChannelProcedureName
- Extend BroadcastChannelRequestPayload with optional OCPP 2.0.1 fields (eventType, evseId, idToken, transactionData)

* feat(ui-service): wire OCPP 2.0.1 commands through UI service pipeline

Add 8 new ProcedureNameToBroadCastChannelProcedureNameMapping entries for:
get15118EVCertificate, getCertificateStatus, logStatusNotification,
notifyCustomerInformation, notifyReport, securityEventNotification,
signCertificate, transactionEvent

* feat(broadcast-channel): add OCPP 2.0.1 command handlers and response mapping

- Add 8 new command handlers: get15118EVCertificate, getCertificateStatus,
  logStatusNotification, notifyCustomerInformation, notifyReport,
  securityEventNotification, signCertificate, transactionEvent
- Update CommandResponse type union with 4 OCPP 2.0.1 response types
- Add 4 commandResponseToResponseStatus cases for non-empty responses
- Fix METER_VALUES handler: add OCPP 2.0.1 passthrough branch (version-detected)
- Fire-and-forget commands handled automatically by isEmpty bailout

* fix(ocpp2): add missing certificate commands to buildRequestPayload

Add SIGN_CERTIFICATE, GET_15118_EV_CERTIFICATE, GET_CERTIFICATE_STATUS
to the passthrough case group — these previously fell to the default
branch which threw OCPPError(NOT_SUPPORTED)

* test(broadcast-channel): add OCPP 2.0.1 UIService pipeline integration tests

Add 45 tests across 6 groups covering:
- ProcedureName enum: 8 new OCPP 2.0.1 entries
- BroadcastChannelProcedureName enum: 8 new OCPP 2.0.1 entries
- ProcedureNameToBroadCastChannelProcedureNameMapping: 8 new entries
- BroadcastChannelRequestPayload: OCPP 2.0.1 optional fields
- commandResponseToResponseStatus: 4 new command response cases
- commandHandlers Map: 8 new entries registered

* style: fix perfectionist ordering in broadcast channel handler and tests

* [autofix.ci] apply automated fixes

* test(broadcast-channel): add buildRequestPayload and behavioral handler tests

Add Group 7 (buildRequestPayload certificate passthrough) and Group 8
(commandHandlers behavioral) tests addressing audit findings:

- 3 tests verify GET_15118_EV_CERTIFICATE, GET_CERTIFICATE_STATUS, and
  SIGN_CERTIFICATE payloads pass through unchanged
- 8 tests verify each OCPP 2.0.1 handler invokes requestHandler with the
  correct RequestCommand enum value

Total: 56 tests in file, 1816 across suite.

* [autofix.ci] apply automated fixes

* refactor(test): remove redundant test groups from broadcast channel tests

Remove Group 4 (BroadcastChannelRequestPayload tautological - type system
already enforces at compile time) and Group 6 (commandHandlers existence -
fully subsumed by Group 8 behavioral tests that verify both existence and
correct RequestCommand routing).

45 tests across 6 groups, 1805 total suite.

* fix: address PR review comments — tighten types, add fire-and-forget response cases, barrel export

- Add explicit commandResponseToResponseStatus cases for 4 fire-and-forget
  commands (LogStatusNotification, NotifyCustomerInformation, NotifyReport,
  SecurityEventNotification) using isEmpty pattern matching existing
  MeterValues/StatusNotification behavior
- Tighten BroadcastChannelRequestPayload types: idToken uses
  OCPP20IdTokenType, rename transactionData to transactionInfo with
  OCPP20TransactionType per OCPP 2.0.1 spec
- Add OCPP20AuthorizationStatusEnumType to barrel export in types/index.ts,
  switch deep imports to barrel imports in source and test files
- Add 8 tests for fire-and-forget commandResponseToResponseStatus coverage
- Fix cspell warnings in test mock data strings

* test: improve OCPP 2.0.1 broadcast channel coverage with dispatch and pipeline tests

Add commandHandler dispatch pipeline tests (Group 6) and requestHandler
full pipeline tests (Group 7) to exercise handler closure bodies through
the actual dispatch path. Convert Group 6 from direct Map.get() to
commandHandler() method dispatch for better V8 coverage tracking.

59 tests across 7 groups (was 53 tests across 6 groups).

* refactor(broadcast-channel): extract handler closures to named private methods

Extract 17 async handler closures from the Map constructor into named
private methods with .bind(this) references. This improves V8 coverage
tracking accuracy for arrow function closures inside Map constructor
arrays, where tsx/esbuild source map misalignment causes inconsistent
coverage attribution.

No behavioral changes — all handlers execute the same logic.

* [autofix.ci] apply automated fixes

* test(broadcast-channel): add mapping completeness validation for UI service command pipeline

* [autofix.ci] apply automated fixes

* refactor(types): remove unused OCPP 2.0 fields from BroadcastChannelRequestPayload

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts
src/charging-station/ocpp/2.0/OCPP20RequestService.ts
src/charging-station/ui-server/ui-services/AbstractUIService.ts
src/types/UIProtocol.ts
src/types/WorkerBroadcastChannel.ts
src/types/index.ts
tests/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.test.ts [new file with mode: 0644]

index 3db5a0a8ee98f62ea17fbcb073d5bc95ebf3a3b2..b5a728b5805412103c81d18c8ee43ab52a2b1824 100644 (file)
@@ -21,11 +21,34 @@ import {
   type EmptyObject,
   type FirmwareStatusNotificationRequest,
   type FirmwareStatusNotificationResponse,
+  GenericStatus,
+  GetCertificateStatusEnumType,
   type HeartbeatRequest,
   type HeartbeatResponse,
+  Iso15118EVCertificateStatusEnumType,
   type MessageEvent,
   type MeterValuesRequest,
   type MeterValuesResponse,
+  OCPP20AuthorizationStatusEnumType,
+  type OCPP20Get15118EVCertificateRequest,
+  type OCPP20Get15118EVCertificateResponse,
+  type OCPP20GetCertificateStatusRequest,
+  type OCPP20GetCertificateStatusResponse,
+  type OCPP20LogStatusNotificationRequest,
+  type OCPP20LogStatusNotificationResponse,
+  type OCPP20MeterValuesRequest,
+  type OCPP20MeterValuesResponse,
+  type OCPP20NotifyCustomerInformationRequest,
+  type OCPP20NotifyCustomerInformationResponse,
+  type OCPP20NotifyReportRequest,
+  type OCPP20NotifyReportResponse,
+  type OCPP20SecurityEventNotificationRequest,
+  type OCPP20SecurityEventNotificationResponse,
+  type OCPP20SignCertificateRequest,
+  type OCPP20SignCertificateResponse,
+  type OCPP20TransactionEventRequest,
+  type OCPP20TransactionEventResponse,
+  OCPPVersion,
   RegistrationStatusEnumType,
   RequestCommand,
   type RequestParams,
@@ -56,71 +79,33 @@ type CommandResponse =
   | DataTransferResponse
   | EmptyObject
   | HeartbeatResponse
+  | OCPP20Get15118EVCertificateResponse
+  | OCPP20GetCertificateStatusResponse
+  | OCPP20SignCertificateResponse
+  | OCPP20TransactionEventResponse
   | StartTransactionResponse
   | StopTransactionResponse
 
 export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChannel {
   private readonly chargingStation: ChargingStation
   private readonly commandHandlers: Map<BroadcastChannelProcedureName, CommandHandler>
+  private readonly requestParams: RequestParams = {
+    throwError: true,
+  }
 
   constructor (chargingStation: ChargingStation) {
     super()
-    const requestParams: RequestParams = {
-      throwError: true,
-    }
+    this.chargingStation = chargingStation
     this.commandHandlers = new Map<BroadcastChannelProcedureName, CommandHandler>([
-      [
-        BroadcastChannelProcedureName.AUTHORIZE,
-        async (requestPayload?: BroadcastChannelRequestPayload) =>
-          await this.chargingStation.ocppRequestService.requestHandler<
-            AuthorizeRequest,
-            AuthorizeResponse
-          >(
-            this.chargingStation,
-            RequestCommand.AUTHORIZE,
-            requestPayload as AuthorizeRequest,
-            requestParams
-          ),
-      ],
-      [
-        BroadcastChannelProcedureName.BOOT_NOTIFICATION,
-        async (requestPayload?: BroadcastChannelRequestPayload) => {
-          return await this.chargingStation.ocppRequestService.requestHandler<
-            BootNotificationRequest,
-            BootNotificationResponse
-          >(
-            this.chargingStation,
-            RequestCommand.BOOT_NOTIFICATION,
-            {
-              ...this.chargingStation.bootNotificationRequest,
-              ...requestPayload,
-            } as BootNotificationRequest,
-            {
-              skipBufferingOnError: true,
-              throwError: true,
-            }
-          )
-        },
-      ],
+      [BroadcastChannelProcedureName.AUTHORIZE, this.handleAuthorize.bind(this)],
+      [BroadcastChannelProcedureName.BOOT_NOTIFICATION, this.handleBootNotification.bind(this)],
       [
         BroadcastChannelProcedureName.CLOSE_CONNECTION,
         () => {
           this.chargingStation.closeWSConnection()
         },
       ],
-      [
-        BroadcastChannelProcedureName.DATA_TRANSFER,
-        async (requestPayload?: BroadcastChannelRequestPayload) =>
-          await this.chargingStation.ocppRequestService.requestHandler<
-            DataTransferRequest,
-            DataTransferResponse
-          >(
-            this.chargingStation,
-            RequestCommand.DATA_TRANSFER,
-            requestPayload as DataTransferRequest,
-            requestParams
-          ),
-      ],
+      [BroadcastChannelProcedureName.DATA_TRANSFER, this.handleDataTransfer.bind(this)],
       [
         BroadcastChannelProcedureName.DELETE_CHARGING_STATIONS,
         async (requestPayload?: BroadcastChannelRequestPayload) => {
@@ -129,83 +114,41 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
       ],
       [
         BroadcastChannelProcedureName.DIAGNOSTICS_STATUS_NOTIFICATION,
-        async (requestPayload?: BroadcastChannelRequestPayload) =>
-          await this.chargingStation.ocppRequestService.requestHandler<
-            DiagnosticsStatusNotificationRequest,
-            DiagnosticsStatusNotificationResponse
-          >(
-            this.chargingStation,
-            RequestCommand.DIAGNOSTICS_STATUS_NOTIFICATION,
-            requestPayload as DiagnosticsStatusNotificationRequest,
-            requestParams
-          ),
+        this.handleDiagnosticsStatusNotification.bind(this),
       ],
       [
         BroadcastChannelProcedureName.FIRMWARE_STATUS_NOTIFICATION,
-        async (requestPayload?: BroadcastChannelRequestPayload) =>
-          await this.chargingStation.ocppRequestService.requestHandler<
-            FirmwareStatusNotificationRequest,
-            FirmwareStatusNotificationResponse
-          >(
-            this.chargingStation,
-            RequestCommand.FIRMWARE_STATUS_NOTIFICATION,
-            requestPayload as FirmwareStatusNotificationRequest,
-            requestParams
-          ),
+        this.handleFirmwareStatusNotification.bind(this),
       ],
       [
-        BroadcastChannelProcedureName.HEARTBEAT,
-        async (requestPayload?: BroadcastChannelRequestPayload) =>
-          await this.chargingStation.ocppRequestService.requestHandler<
-            HeartbeatRequest,
-            HeartbeatResponse
-          >(
-            this.chargingStation,
-            RequestCommand.HEARTBEAT,
-            requestPayload as HeartbeatRequest,
-            requestParams
-          ),
+        BroadcastChannelProcedureName.GET_15118_EV_CERTIFICATE,
+        this.handleGet15118EVCertificate.bind(this),
       ],
       [
-        BroadcastChannelProcedureName.METER_VALUES,
-        async (requestPayload?: BroadcastChannelRequestPayload) => {
-          const configuredMeterValueSampleInterval = getConfigurationKey(
-            chargingStation,
-            StandardParametersKey.MeterValueSampleInterval
-          )
-          const connectorId = requestPayload?.connectorId
-          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-          const transactionId = this.chargingStation.getConnectorStatus(connectorId!)?.transactionId
-          return await this.chargingStation.ocppRequestService.requestHandler<
-            MeterValuesRequest,
-            MeterValuesResponse
-          >(
-            this.chargingStation,
-            RequestCommand.METER_VALUES,
-            {
-              meterValue: [
-                buildMeterValue(
-                  this.chargingStation,
-                  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                  connectorId!,
-                  convertToInt(transactionId),
-                  configuredMeterValueSampleInterval != null
-                    ? secondsToMilliseconds(convertToInt(configuredMeterValueSampleInterval.value))
-                    : Constants.DEFAULT_METER_VALUES_INTERVAL
-                ),
-              ],
-              ...requestPayload,
-            } as MeterValuesRequest,
-            requestParams
-          )
-        },
+        BroadcastChannelProcedureName.GET_CERTIFICATE_STATUS,
+        this.handleGetCertificateStatus.bind(this),
+      ],
+      [BroadcastChannelProcedureName.HEARTBEAT, this.handleHeartbeat.bind(this)],
+      [
+        BroadcastChannelProcedureName.LOG_STATUS_NOTIFICATION,
+        this.handleLogStatusNotification.bind(this),
+      ],
+      [BroadcastChannelProcedureName.METER_VALUES, this.handleMeterValues.bind(this)],
+      [
+        BroadcastChannelProcedureName.NOTIFY_CUSTOMER_INFORMATION,
+        this.handleNotifyCustomerInformation.bind(this),
       ],
+      [BroadcastChannelProcedureName.NOTIFY_REPORT, this.handleNotifyReport.bind(this)],
       [
         BroadcastChannelProcedureName.OPEN_CONNECTION,
         () => {
           this.chargingStation.openWSConnection()
         },
       ],
+      [
+        BroadcastChannelProcedureName.SECURITY_EVENT_NOTIFICATION,
+        this.handleSecurityEventNotification.bind(this),
+      ],
       [
         BroadcastChannelProcedureName.SET_SUPERVISION_URL,
         (requestPayload?: BroadcastChannelRequestPayload) => {
@@ -218,6 +161,7 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
           this.chargingStation.setSupervisionUrl(url)
         },
       ],
+      [BroadcastChannelProcedureName.SIGN_CERTIFICATE, this.handleSignCertificate.bind(this)],
       [
         BroadcastChannelProcedureName.START_AUTOMATIC_TRANSACTION_GENERATOR,
         (requestPayload?: BroadcastChannelRequestPayload) => {
@@ -230,32 +174,8 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
           this.chargingStation.start()
         },
       ],
-      [
-        BroadcastChannelProcedureName.START_TRANSACTION,
-        async (requestPayload?: BroadcastChannelRequestPayload) =>
-          await this.chargingStation.ocppRequestService.requestHandler<
-            StartTransactionRequest,
-            StartTransactionResponse
-          >(
-            this.chargingStation,
-            RequestCommand.START_TRANSACTION,
-            requestPayload as StartTransactionRequest,
-            requestParams
-          ),
-      ],
-      [
-        BroadcastChannelProcedureName.STATUS_NOTIFICATION,
-        async (requestPayload?: BroadcastChannelRequestPayload) =>
-          await this.chargingStation.ocppRequestService.requestHandler<
-            StatusNotificationRequest,
-            StatusNotificationResponse
-          >(
-            this.chargingStation,
-            RequestCommand.STATUS_NOTIFICATION,
-            requestPayload as StatusNotificationRequest,
-            requestParams
-          ),
-      ],
+      [BroadcastChannelProcedureName.START_TRANSACTION, this.handleStartTransaction.bind(this)],
+      [BroadcastChannelProcedureName.STATUS_NOTIFICATION, this.handleStatusNotification.bind(this)],
       [
         BroadcastChannelProcedureName.STOP_AUTOMATIC_TRANSACTION_GENERATOR,
         (requestPayload?: BroadcastChannelRequestPayload) => {
@@ -268,27 +188,9 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
           await this.chargingStation.stop()
         },
       ],
-      [
-        BroadcastChannelProcedureName.STOP_TRANSACTION,
-        async (requestPayload?: BroadcastChannelRequestPayload) =>
-          await this.chargingStation.ocppRequestService.requestHandler<
-            StopTransactionRequest,
-            StopTransactionResponse
-          >(
-            this.chargingStation,
-            RequestCommand.STOP_TRANSACTION,
-            {
-              meterStop: this.chargingStation.getEnergyActiveImportRegisterByTransactionId(
-                requestPayload?.transactionId,
-                true
-              ),
-              ...requestPayload,
-            } as StopTransactionRequest,
-            requestParams
-          ),
-      ],
+      [BroadcastChannelProcedureName.STOP_TRANSACTION, this.handleStopTransaction.bind(this)],
+      [BroadcastChannelProcedureName.TRANSACTION_EVENT, this.handleTransactionEvent.bind(this)],
     ])
-    this.chargingStation = chargingStation
     this.onmessage = this.requestHandler.bind(this) as (message: unknown) => void
     this.onmessageerror = this.messageErrorHandler.bind(this) as (message: unknown) => void
   }
@@ -377,22 +279,352 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
           return ResponseStatus.SUCCESS
         }
         return ResponseStatus.FAILURE
+      case BroadcastChannelProcedureName.GET_15118_EV_CERTIFICATE:
+        if (
+          (commandResponse as OCPP20Get15118EVCertificateResponse).status ===
+          Iso15118EVCertificateStatusEnumType.Accepted
+        ) {
+          return ResponseStatus.SUCCESS
+        }
+        return ResponseStatus.FAILURE
+      case BroadcastChannelProcedureName.GET_CERTIFICATE_STATUS:
+        if (
+          (commandResponse as OCPP20GetCertificateStatusResponse).status ===
+          GetCertificateStatusEnumType.Accepted
+        ) {
+          return ResponseStatus.SUCCESS
+        }
+        return ResponseStatus.FAILURE
       case BroadcastChannelProcedureName.HEARTBEAT:
         if ('currentTime' in commandResponse) {
           return ResponseStatus.SUCCESS
         }
         return ResponseStatus.FAILURE
+      case BroadcastChannelProcedureName.LOG_STATUS_NOTIFICATION:
       case BroadcastChannelProcedureName.METER_VALUES:
+      case BroadcastChannelProcedureName.NOTIFY_CUSTOMER_INFORMATION:
+      case BroadcastChannelProcedureName.NOTIFY_REPORT:
+      case BroadcastChannelProcedureName.SECURITY_EVENT_NOTIFICATION:
       case BroadcastChannelProcedureName.STATUS_NOTIFICATION:
         if (isEmpty(commandResponse)) {
           return ResponseStatus.SUCCESS
         }
         return ResponseStatus.FAILURE
+      case BroadcastChannelProcedureName.SIGN_CERTIFICATE:
+        if ((commandResponse as OCPP20SignCertificateResponse).status === GenericStatus.Accepted) {
+          return ResponseStatus.SUCCESS
+        }
+        return ResponseStatus.FAILURE
+      case BroadcastChannelProcedureName.TRANSACTION_EVENT:
+        if (
+          isEmpty(commandResponse) ||
+          (commandResponse as OCPP20TransactionEventResponse).idTokenInfo == null ||
+          (commandResponse as OCPP20TransactionEventResponse).idTokenInfo?.status ===
+            OCPP20AuthorizationStatusEnumType.Accepted
+        ) {
+          return ResponseStatus.SUCCESS
+        }
+        return ResponseStatus.FAILURE
       default:
         return ResponseStatus.FAILURE
     }
   }
 
+  private async handleAuthorize (
+    requestPayload?: BroadcastChannelRequestPayload
+  ): Promise<AuthorizeResponse> {
+    return await this.chargingStation.ocppRequestService.requestHandler<
+      AuthorizeRequest,
+      AuthorizeResponse
+    >(
+      this.chargingStation,
+      RequestCommand.AUTHORIZE,
+      requestPayload as AuthorizeRequest,
+      this.requestParams
+    )
+  }
+
+  private async handleBootNotification (
+    requestPayload?: BroadcastChannelRequestPayload
+  ): Promise<BootNotificationResponse> {
+    return await this.chargingStation.ocppRequestService.requestHandler<
+      BootNotificationRequest,
+      BootNotificationResponse
+    >(
+      this.chargingStation,
+      RequestCommand.BOOT_NOTIFICATION,
+      {
+        ...this.chargingStation.bootNotificationRequest,
+        ...requestPayload,
+      } as BootNotificationRequest,
+      {
+        skipBufferingOnError: true,
+        throwError: true,
+      }
+    )
+  }
+
+  private async handleDataTransfer (
+    requestPayload?: BroadcastChannelRequestPayload
+  ): Promise<DataTransferResponse> {
+    return await this.chargingStation.ocppRequestService.requestHandler<
+      DataTransferRequest,
+      DataTransferResponse
+    >(
+      this.chargingStation,
+      RequestCommand.DATA_TRANSFER,
+      requestPayload as DataTransferRequest,
+      this.requestParams
+    )
+  }
+
+  private async handleDiagnosticsStatusNotification (
+    requestPayload?: BroadcastChannelRequestPayload
+  ): Promise<DiagnosticsStatusNotificationResponse> {
+    return await this.chargingStation.ocppRequestService.requestHandler<
+      DiagnosticsStatusNotificationRequest,
+      DiagnosticsStatusNotificationResponse
+    >(
+      this.chargingStation,
+      RequestCommand.DIAGNOSTICS_STATUS_NOTIFICATION,
+      requestPayload as DiagnosticsStatusNotificationRequest,
+      this.requestParams
+    )
+  }
+
+  private async handleFirmwareStatusNotification (
+    requestPayload?: BroadcastChannelRequestPayload
+  ): Promise<FirmwareStatusNotificationResponse> {
+    return await this.chargingStation.ocppRequestService.requestHandler<
+      FirmwareStatusNotificationRequest,
+      FirmwareStatusNotificationResponse
+    >(
+      this.chargingStation,
+      RequestCommand.FIRMWARE_STATUS_NOTIFICATION,
+      requestPayload as FirmwareStatusNotificationRequest,
+      this.requestParams
+    )
+  }
+
+  private async handleGet15118EVCertificate (
+    requestPayload?: BroadcastChannelRequestPayload
+  ): Promise<OCPP20Get15118EVCertificateResponse> {
+    return await this.chargingStation.ocppRequestService.requestHandler<
+      OCPP20Get15118EVCertificateRequest,
+      OCPP20Get15118EVCertificateResponse
+    >(
+      this.chargingStation,
+      RequestCommand.GET_15118_EV_CERTIFICATE,
+      requestPayload as OCPP20Get15118EVCertificateRequest,
+      this.requestParams
+    )
+  }
+
+  private async handleGetCertificateStatus (
+    requestPayload?: BroadcastChannelRequestPayload
+  ): Promise<OCPP20GetCertificateStatusResponse> {
+    return await this.chargingStation.ocppRequestService.requestHandler<
+      OCPP20GetCertificateStatusRequest,
+      OCPP20GetCertificateStatusResponse
+    >(
+      this.chargingStation,
+      RequestCommand.GET_CERTIFICATE_STATUS,
+      requestPayload as OCPP20GetCertificateStatusRequest,
+      this.requestParams
+    )
+  }
+
+  private async handleHeartbeat (
+    requestPayload?: BroadcastChannelRequestPayload
+  ): Promise<HeartbeatResponse> {
+    return await this.chargingStation.ocppRequestService.requestHandler<
+      HeartbeatRequest,
+      HeartbeatResponse
+    >(
+      this.chargingStation,
+      RequestCommand.HEARTBEAT,
+      requestPayload as HeartbeatRequest,
+      this.requestParams
+    )
+  }
+
+  private async handleLogStatusNotification (
+    requestPayload?: BroadcastChannelRequestPayload
+  ): Promise<OCPP20LogStatusNotificationResponse> {
+    return await this.chargingStation.ocppRequestService.requestHandler<
+      OCPP20LogStatusNotificationRequest,
+      OCPP20LogStatusNotificationResponse
+    >(
+      this.chargingStation,
+      RequestCommand.LOG_STATUS_NOTIFICATION,
+      requestPayload as OCPP20LogStatusNotificationRequest,
+      this.requestParams
+    )
+  }
+
+  private async handleMeterValues (
+    requestPayload?: BroadcastChannelRequestPayload
+  ): Promise<MeterValuesResponse> {
+    if (this.chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_201) {
+      return await this.chargingStation.ocppRequestService.requestHandler<
+        OCPP20MeterValuesRequest,
+        OCPP20MeterValuesResponse
+      >(
+        this.chargingStation,
+        RequestCommand.METER_VALUES,
+        requestPayload as OCPP20MeterValuesRequest,
+        this.requestParams
+      )
+    }
+    const configuredMeterValueSampleInterval = getConfigurationKey(
+      this.chargingStation,
+      StandardParametersKey.MeterValueSampleInterval
+    )
+    const connectorId = requestPayload?.connectorId
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const transactionId = this.chargingStation.getConnectorStatus(connectorId!)?.transactionId
+    return await this.chargingStation.ocppRequestService.requestHandler<
+      MeterValuesRequest,
+      MeterValuesResponse
+    >(
+      this.chargingStation,
+      RequestCommand.METER_VALUES,
+      {
+        meterValue: [
+          buildMeterValue(
+            this.chargingStation,
+            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+            connectorId!,
+            convertToInt(transactionId),
+            configuredMeterValueSampleInterval != null
+              ? secondsToMilliseconds(convertToInt(configuredMeterValueSampleInterval.value))
+              : Constants.DEFAULT_METER_VALUES_INTERVAL
+          ),
+        ],
+        ...requestPayload,
+      } as MeterValuesRequest,
+      this.requestParams
+    )
+  }
+
+  private async handleNotifyCustomerInformation (
+    requestPayload?: BroadcastChannelRequestPayload
+  ): Promise<OCPP20NotifyCustomerInformationResponse> {
+    return await this.chargingStation.ocppRequestService.requestHandler<
+      OCPP20NotifyCustomerInformationRequest,
+      OCPP20NotifyCustomerInformationResponse
+    >(
+      this.chargingStation,
+      RequestCommand.NOTIFY_CUSTOMER_INFORMATION,
+      requestPayload as OCPP20NotifyCustomerInformationRequest,
+      this.requestParams
+    )
+  }
+
+  private async handleNotifyReport (
+    requestPayload?: BroadcastChannelRequestPayload
+  ): Promise<OCPP20NotifyReportResponse> {
+    return await this.chargingStation.ocppRequestService.requestHandler<
+      OCPP20NotifyReportRequest,
+      OCPP20NotifyReportResponse
+    >(
+      this.chargingStation,
+      RequestCommand.NOTIFY_REPORT,
+      requestPayload as OCPP20NotifyReportRequest,
+      this.requestParams
+    )
+  }
+
+  private async handleSecurityEventNotification (
+    requestPayload?: BroadcastChannelRequestPayload
+  ): Promise<OCPP20SecurityEventNotificationResponse> {
+    return await this.chargingStation.ocppRequestService.requestHandler<
+      OCPP20SecurityEventNotificationRequest,
+      OCPP20SecurityEventNotificationResponse
+    >(
+      this.chargingStation,
+      RequestCommand.SECURITY_EVENT_NOTIFICATION,
+      requestPayload as OCPP20SecurityEventNotificationRequest,
+      this.requestParams
+    )
+  }
+
+  private async handleSignCertificate (
+    requestPayload?: BroadcastChannelRequestPayload
+  ): Promise<OCPP20SignCertificateResponse> {
+    return await this.chargingStation.ocppRequestService.requestHandler<
+      OCPP20SignCertificateRequest,
+      OCPP20SignCertificateResponse
+    >(
+      this.chargingStation,
+      RequestCommand.SIGN_CERTIFICATE,
+      requestPayload as OCPP20SignCertificateRequest,
+      this.requestParams
+    )
+  }
+
+  private async handleStartTransaction (
+    requestPayload?: BroadcastChannelRequestPayload
+  ): Promise<StartTransactionResponse> {
+    return await this.chargingStation.ocppRequestService.requestHandler<
+      StartTransactionRequest,
+      StartTransactionResponse
+    >(
+      this.chargingStation,
+      RequestCommand.START_TRANSACTION,
+      requestPayload as StartTransactionRequest,
+      this.requestParams
+    )
+  }
+
+  private async handleStatusNotification (
+    requestPayload?: BroadcastChannelRequestPayload
+  ): Promise<StatusNotificationResponse> {
+    return await this.chargingStation.ocppRequestService.requestHandler<
+      StatusNotificationRequest,
+      StatusNotificationResponse
+    >(
+      this.chargingStation,
+      RequestCommand.STATUS_NOTIFICATION,
+      requestPayload as StatusNotificationRequest,
+      this.requestParams
+    )
+  }
+
+  private async handleStopTransaction (
+    requestPayload?: BroadcastChannelRequestPayload
+  ): Promise<StopTransactionResponse> {
+    return await this.chargingStation.ocppRequestService.requestHandler<
+      StopTransactionRequest,
+      StopTransactionResponse
+    >(
+      this.chargingStation,
+      RequestCommand.STOP_TRANSACTION,
+      {
+        meterStop: this.chargingStation.getEnergyActiveImportRegisterByTransactionId(
+          requestPayload?.transactionId,
+          true
+        ),
+        ...requestPayload,
+      } as StopTransactionRequest,
+      this.requestParams
+    )
+  }
+
+  private async handleTransactionEvent (
+    requestPayload?: BroadcastChannelRequestPayload
+  ): Promise<OCPP20TransactionEventResponse> {
+    return await this.chargingStation.ocppRequestService.requestHandler<
+      OCPP20TransactionEventRequest,
+      OCPP20TransactionEventResponse
+    >(
+      this.chargingStation,
+      RequestCommand.TRANSACTION_EVENT,
+      requestPayload as OCPP20TransactionEventRequest,
+      this.requestParams
+    )
+  }
+
   private messageErrorHandler (messageEvent: MessageEvent): void {
     logger.error(
       `${this.chargingStation.logPrefix()} ${moduleName}.messageErrorHandler: Error at handling message:`,
index 1dae156f873414275ff36ae54901a4847231000e..5be8edcf29d875aa968280bd7d4e910c6f46bd62 100644 (file)
@@ -593,10 +593,13 @@ export class OCPP20RequestService extends OCPPRequestService {
       case OCPP20RequestCommand.BOOT_NOTIFICATION:
         return commandParams as unknown as Request
       case OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION:
+      case OCPP20RequestCommand.GET_15118_EV_CERTIFICATE:
+      case OCPP20RequestCommand.GET_CERTIFICATE_STATUS:
       case OCPP20RequestCommand.LOG_STATUS_NOTIFICATION:
       case OCPP20RequestCommand.METER_VALUES:
       case OCPP20RequestCommand.NOTIFY_CUSTOMER_INFORMATION:
       case OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION:
+      case OCPP20RequestCommand.SIGN_CERTIFICATE:
         return commandParams as unknown as Request
       case OCPP20RequestCommand.HEARTBEAT:
         return OCPP20Constants.OCPP_RESPONSE_EMPTY as unknown as Request
index 4273e5dfc7a733273f5efd7272ccbba2c129e24f..98b0e1a7383f7295515bc4ba31efc8fa1455d791 100644 (file)
@@ -54,10 +54,26 @@ export abstract class AbstractUIService {
       ProcedureName.FIRMWARE_STATUS_NOTIFICATION,
       BroadcastChannelProcedureName.FIRMWARE_STATUS_NOTIFICATION,
     ],
+    [
+      ProcedureName.GET_15118_EV_CERTIFICATE,
+      BroadcastChannelProcedureName.GET_15118_EV_CERTIFICATE,
+    ],
+    [ProcedureName.GET_CERTIFICATE_STATUS, BroadcastChannelProcedureName.GET_CERTIFICATE_STATUS],
     [ProcedureName.HEARTBEAT, BroadcastChannelProcedureName.HEARTBEAT],
+    [ProcedureName.LOG_STATUS_NOTIFICATION, BroadcastChannelProcedureName.LOG_STATUS_NOTIFICATION],
     [ProcedureName.METER_VALUES, BroadcastChannelProcedureName.METER_VALUES],
+    [
+      ProcedureName.NOTIFY_CUSTOMER_INFORMATION,
+      BroadcastChannelProcedureName.NOTIFY_CUSTOMER_INFORMATION,
+    ],
+    [ProcedureName.NOTIFY_REPORT, BroadcastChannelProcedureName.NOTIFY_REPORT],
     [ProcedureName.OPEN_CONNECTION, BroadcastChannelProcedureName.OPEN_CONNECTION],
+    [
+      ProcedureName.SECURITY_EVENT_NOTIFICATION,
+      BroadcastChannelProcedureName.SECURITY_EVENT_NOTIFICATION,
+    ],
     [ProcedureName.SET_SUPERVISION_URL, BroadcastChannelProcedureName.SET_SUPERVISION_URL],
+    [ProcedureName.SIGN_CERTIFICATE, BroadcastChannelProcedureName.SIGN_CERTIFICATE],
     [
       ProcedureName.START_AUTOMATIC_TRANSACTION_GENERATOR,
       BroadcastChannelProcedureName.START_AUTOMATIC_TRANSACTION_GENERATOR,
@@ -71,6 +87,7 @@ export abstract class AbstractUIService {
     ],
     [ProcedureName.STOP_CHARGING_STATION, BroadcastChannelProcedureName.STOP_CHARGING_STATION],
     [ProcedureName.STOP_TRANSACTION, BroadcastChannelProcedureName.STOP_TRANSACTION],
+    [ProcedureName.TRANSACTION_EVENT, BroadcastChannelProcedureName.TRANSACTION_EVENT],
   ])
 
   protected readonly requestHandlers: Map<ProcedureName, ProtocolRequestHandler>
index 222387322497e103784beed85d7505fa0c1418ff..76e60db57416bbe27e4856d174860f47ae3f976d 100644 (file)
@@ -21,13 +21,20 @@ export enum ProcedureName {
   DELETE_CHARGING_STATIONS = 'deleteChargingStations',
   DIAGNOSTICS_STATUS_NOTIFICATION = 'diagnosticsStatusNotification',
   FIRMWARE_STATUS_NOTIFICATION = 'firmwareStatusNotification',
+  GET_15118_EV_CERTIFICATE = 'get15118EVCertificate',
+  GET_CERTIFICATE_STATUS = 'getCertificateStatus',
   HEARTBEAT = 'heartbeat',
   LIST_CHARGING_STATIONS = 'listChargingStations',
   LIST_TEMPLATES = 'listTemplates',
+  LOG_STATUS_NOTIFICATION = 'logStatusNotification',
   METER_VALUES = 'meterValues',
+  NOTIFY_CUSTOMER_INFORMATION = 'notifyCustomerInformation',
+  NOTIFY_REPORT = 'notifyReport',
   OPEN_CONNECTION = 'openConnection',
   PERFORMANCE_STATISTICS = 'performanceStatistics',
+  SECURITY_EVENT_NOTIFICATION = 'securityEventNotification',
   SET_SUPERVISION_URL = 'setSupervisionUrl',
+  SIGN_CERTIFICATE = 'signCertificate',
   SIMULATOR_STATE = 'simulatorState',
   START_AUTOMATIC_TRANSACTION_GENERATOR = 'startAutomaticTransactionGenerator',
   START_CHARGING_STATION = 'startChargingStation',
@@ -38,6 +45,7 @@ export enum ProcedureName {
   STOP_CHARGING_STATION = 'stopChargingStation',
   STOP_SIMULATOR = 'stopSimulator',
   STOP_TRANSACTION = 'stopTransaction',
+  TRANSACTION_EVENT = 'transactionEvent',
 }
 
 export enum Protocol {
index 28ff6bd20a60631cea790805e679e17549298056..e783e77d3d92963c9703804197f45b23b99e2b3b 100644 (file)
@@ -9,10 +9,17 @@ export enum BroadcastChannelProcedureName {
   DELETE_CHARGING_STATIONS = 'deleteChargingStations',
   DIAGNOSTICS_STATUS_NOTIFICATION = 'diagnosticsStatusNotification',
   FIRMWARE_STATUS_NOTIFICATION = 'firmwareStatusNotification',
+  GET_15118_EV_CERTIFICATE = 'get15118EVCertificate',
+  GET_CERTIFICATE_STATUS = 'getCertificateStatus',
   HEARTBEAT = 'heartbeat',
+  LOG_STATUS_NOTIFICATION = 'logStatusNotification',
   METER_VALUES = 'meterValues',
+  NOTIFY_CUSTOMER_INFORMATION = 'notifyCustomerInformation',
+  NOTIFY_REPORT = 'notifyReport',
   OPEN_CONNECTION = 'openConnection',
+  SECURITY_EVENT_NOTIFICATION = 'securityEventNotification',
   SET_SUPERVISION_URL = 'setSupervisionUrl',
+  SIGN_CERTIFICATE = 'signCertificate',
   START_AUTOMATIC_TRANSACTION_GENERATOR = 'startAutomaticTransactionGenerator',
   START_CHARGING_STATION = 'startChargingStation',
   START_TRANSACTION = 'startTransaction',
@@ -20,6 +27,7 @@ export enum BroadcastChannelProcedureName {
   STOP_AUTOMATIC_TRANSACTION_GENERATOR = 'stopAutomaticTransactionGenerator',
   STOP_CHARGING_STATION = 'stopChargingStation',
   STOP_TRANSACTION = 'stopTransaction',
+  TRANSACTION_EVENT = 'transactionEvent',
 }
 
 export type BroadcastChannelRequest = [
index 8e098d67527bf793b961e44f9672cc453cf1a660..24f41f49ad5c0f4bf020667419729ef88256f679 100644 (file)
@@ -264,6 +264,7 @@ export type {
 } from './ocpp/2.0/Responses.js'
 export {
   type ComponentType,
+  OCPP20AuthorizationStatusEnumType,
   OCPP20ChargingProfileKindEnumType,
   OCPP20ChargingProfilePurposeEnumType,
   type OCPP20ChargingProfileType,
diff --git a/tests/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.test.ts b/tests/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.test.ts
new file mode 100644 (file)
index 0000000..e6d9281
--- /dev/null
@@ -0,0 +1,977 @@
+/**
+ * @file Tests for ChargingStationWorkerBroadcastChannel
+ * @description Verifies OCPP 2.0.1 UIService pipeline integration: enums, mappings,
+ * response status logic, payload building, and handler routing for the 8 new broadcast
+ * channel procedures. 59 tests across 7 groups.
+ */
+
+import assert from 'node:assert/strict'
+import { randomUUID } from 'node:crypto'
+import { afterEach, describe, it } from 'node:test'
+
+import { ChargingStationWorkerBroadcastChannel } from '../../../src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.js'
+import { AbstractUIService } from '../../../src/charging-station/ui-server/ui-services/AbstractUIService.js'
+import {
+  BroadcastChannelProcedureName,
+  type BroadcastChannelRequestPayload,
+  GenericStatus,
+  GetCertificateStatusEnumType,
+  Iso15118EVCertificateStatusEnumType,
+  OCPP20AuthorizationStatusEnumType,
+  OCPP20RequestCommand,
+  OCPPVersion,
+  ProcedureName,
+  RequestCommand,
+  ResponseStatus,
+} from '../../../src/types/index.js'
+import { Constants } from '../../../src/utils/index.js'
+import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
+import { createMockChargingStation } from '../ChargingStationTestUtils.js'
+import {
+  createMockStationWithRequestTracking,
+  createOCPP20RequestTestContext,
+} from '../ocpp/2.0/OCPP20TestUtils.js'
+
+// ============================================================================
+// Testable Interfaces
+// ============================================================================
+// Type-safe access to private/protected members for testing, following the
+// pattern from OCPP20TestUtils.ts to avoid `as any` casts.
+// ============================================================================
+
+type CommandHandler = (
+  requestPayload?: BroadcastChannelRequestPayload
+) => Promise<unknown> | undefined
+
+/**
+ * Interface exposing protected static members of AbstractUIService for testing.
+ */
+interface TestableAbstractUIService {
+  ProcedureNameToBroadCastChannelProcedureNameMapping: Map<
+    ProcedureName,
+    BroadcastChannelProcedureName
+  >
+}
+
+interface TestableWorkerBroadcastChannel {
+  commandHandler: (
+    command: BroadcastChannelProcedureName,
+    requestPayload: BroadcastChannelRequestPayload
+  ) => Promise<unknown>
+  commandHandlers: Map<BroadcastChannelProcedureName, CommandHandler>
+  commandResponseToResponseStatus: (
+    command: BroadcastChannelProcedureName,
+    commandResponse: unknown
+  ) => ResponseStatus
+  requestHandler: (messageEvent: { data: unknown }) => void
+}
+
+/**
+ * Create a testable wrapper for ChargingStationWorkerBroadcastChannel.
+ * @param instance - The instance to wrap
+ * @returns Testable interface with access to private members
+ */
+function createTestableWorkerBroadcastChannel (
+  instance: ChargingStationWorkerBroadcastChannel
+): TestableWorkerBroadcastChannel {
+  const testable = instance as unknown as TestableWorkerBroadcastChannel
+  return {
+    commandHandler: testable.commandHandler.bind(instance),
+    commandHandlers: testable.commandHandlers,
+    commandResponseToResponseStatus: testable.commandResponseToResponseStatus.bind(instance),
+    requestHandler: testable.requestHandler.bind(instance),
+  }
+}
+
+/**
+ * Get the protected static ProcedureNameToBroadCastChannelProcedureNameMapping.
+ * @returns The mapping from ProcedureName to BroadcastChannelProcedureName
+ */
+function getProcedureNameMapping (): Map<ProcedureName, BroadcastChannelProcedureName> {
+  return (AbstractUIService as unknown as TestableAbstractUIService)
+    .ProcedureNameToBroadCastChannelProcedureNameMapping
+}
+
+await describe('ChargingStationWorkerBroadcastChannel', async () => {
+  let instance: ChargingStationWorkerBroadcastChannel | undefined
+
+  afterEach(() => {
+    if (instance != null) {
+      instance.close()
+      instance = undefined
+    }
+    standardCleanup()
+  })
+
+  // ==========================================================================
+  // Group 1: ProcedureName enum — 8 new OCPP 2.0.1 entries (8 tests)
+  // ==========================================================================
+
+  await describe('ProcedureName enum OCPP 2.0.1 entries', async () => {
+    await it('should have GET_15118_EV_CERTIFICATE in ProcedureName enum', () => {
+      assert.strictEqual(ProcedureName.GET_15118_EV_CERTIFICATE, 'get15118EVCertificate')
+    })
+
+    await it('should have GET_CERTIFICATE_STATUS in ProcedureName enum', () => {
+      assert.strictEqual(ProcedureName.GET_CERTIFICATE_STATUS, 'getCertificateStatus')
+    })
+
+    await it('should have LOG_STATUS_NOTIFICATION in ProcedureName enum', () => {
+      assert.strictEqual(ProcedureName.LOG_STATUS_NOTIFICATION, 'logStatusNotification')
+    })
+
+    await it('should have NOTIFY_CUSTOMER_INFORMATION in ProcedureName enum', () => {
+      assert.strictEqual(ProcedureName.NOTIFY_CUSTOMER_INFORMATION, 'notifyCustomerInformation')
+    })
+
+    await it('should have NOTIFY_REPORT in ProcedureName enum', () => {
+      assert.strictEqual(ProcedureName.NOTIFY_REPORT, 'notifyReport')
+    })
+
+    await it('should have SECURITY_EVENT_NOTIFICATION in ProcedureName enum', () => {
+      assert.strictEqual(ProcedureName.SECURITY_EVENT_NOTIFICATION, 'securityEventNotification')
+    })
+
+    await it('should have SIGN_CERTIFICATE in ProcedureName enum', () => {
+      assert.strictEqual(ProcedureName.SIGN_CERTIFICATE, 'signCertificate')
+    })
+
+    await it('should have TRANSACTION_EVENT in ProcedureName enum', () => {
+      assert.strictEqual(ProcedureName.TRANSACTION_EVENT, 'transactionEvent')
+    })
+  })
+
+  // ==========================================================================
+  // Group 2: BroadcastChannelProcedureName enum — 8 new OCPP 2.0.1 entries (8 tests)
+  // ==========================================================================
+
+  await describe('BroadcastChannelProcedureName enum OCPP 2.0.1 entries', async () => {
+    await it('should have GET_15118_EV_CERTIFICATE in BroadcastChannelProcedureName enum', () => {
+      assert.strictEqual(
+        BroadcastChannelProcedureName.GET_15118_EV_CERTIFICATE,
+        'get15118EVCertificate'
+      )
+    })
+
+    await it('should have GET_CERTIFICATE_STATUS in BroadcastChannelProcedureName enum', () => {
+      assert.strictEqual(
+        BroadcastChannelProcedureName.GET_CERTIFICATE_STATUS,
+        'getCertificateStatus'
+      )
+    })
+
+    await it('should have LOG_STATUS_NOTIFICATION in BroadcastChannelProcedureName enum', () => {
+      assert.strictEqual(
+        BroadcastChannelProcedureName.LOG_STATUS_NOTIFICATION,
+        'logStatusNotification'
+      )
+    })
+
+    await it('should have NOTIFY_CUSTOMER_INFORMATION in BroadcastChannelProcedureName enum', () => {
+      assert.strictEqual(
+        BroadcastChannelProcedureName.NOTIFY_CUSTOMER_INFORMATION,
+        'notifyCustomerInformation'
+      )
+    })
+
+    await it('should have NOTIFY_REPORT in BroadcastChannelProcedureName enum', () => {
+      assert.strictEqual(BroadcastChannelProcedureName.NOTIFY_REPORT, 'notifyReport')
+    })
+
+    await it('should have SECURITY_EVENT_NOTIFICATION in BroadcastChannelProcedureName enum', () => {
+      assert.strictEqual(
+        BroadcastChannelProcedureName.SECURITY_EVENT_NOTIFICATION,
+        'securityEventNotification'
+      )
+    })
+
+    await it('should have SIGN_CERTIFICATE in BroadcastChannelProcedureName enum', () => {
+      assert.strictEqual(BroadcastChannelProcedureName.SIGN_CERTIFICATE, 'signCertificate')
+    })
+
+    await it('should have TRANSACTION_EVENT in BroadcastChannelProcedureName enum', () => {
+      assert.strictEqual(BroadcastChannelProcedureName.TRANSACTION_EVENT, 'transactionEvent')
+    })
+  })
+
+  // ==========================================================================
+  // Group 3: ProcedureNameToBroadCastChannelProcedureNameMapping — 8 new entries (8 tests)
+  // ==========================================================================
+
+  await describe('ProcedureNameToBroadCastChannelProcedureNameMapping OCPP 2.0.1 entries', async () => {
+    await it('should map GET_15118_EV_CERTIFICATE procedure to broadcast channel procedure', () => {
+      const mapping = getProcedureNameMapping()
+      assert.strictEqual(
+        mapping.get(ProcedureName.GET_15118_EV_CERTIFICATE),
+        BroadcastChannelProcedureName.GET_15118_EV_CERTIFICATE
+      )
+    })
+
+    await it('should map GET_CERTIFICATE_STATUS procedure to broadcast channel procedure', () => {
+      const mapping = getProcedureNameMapping()
+      assert.strictEqual(
+        mapping.get(ProcedureName.GET_CERTIFICATE_STATUS),
+        BroadcastChannelProcedureName.GET_CERTIFICATE_STATUS
+      )
+    })
+
+    await it('should map LOG_STATUS_NOTIFICATION procedure to broadcast channel procedure', () => {
+      const mapping = getProcedureNameMapping()
+      assert.strictEqual(
+        mapping.get(ProcedureName.LOG_STATUS_NOTIFICATION),
+        BroadcastChannelProcedureName.LOG_STATUS_NOTIFICATION
+      )
+    })
+
+    await it('should map NOTIFY_CUSTOMER_INFORMATION procedure to broadcast channel procedure', () => {
+      const mapping = getProcedureNameMapping()
+      assert.strictEqual(
+        mapping.get(ProcedureName.NOTIFY_CUSTOMER_INFORMATION),
+        BroadcastChannelProcedureName.NOTIFY_CUSTOMER_INFORMATION
+      )
+    })
+
+    await it('should map NOTIFY_REPORT procedure to broadcast channel procedure', () => {
+      const mapping = getProcedureNameMapping()
+      assert.strictEqual(
+        mapping.get(ProcedureName.NOTIFY_REPORT),
+        BroadcastChannelProcedureName.NOTIFY_REPORT
+      )
+    })
+
+    await it('should map SECURITY_EVENT_NOTIFICATION procedure to broadcast channel procedure', () => {
+      const mapping = getProcedureNameMapping()
+      assert.strictEqual(
+        mapping.get(ProcedureName.SECURITY_EVENT_NOTIFICATION),
+        BroadcastChannelProcedureName.SECURITY_EVENT_NOTIFICATION
+      )
+    })
+
+    await it('should map SIGN_CERTIFICATE procedure to broadcast channel procedure', () => {
+      const mapping = getProcedureNameMapping()
+      assert.strictEqual(
+        mapping.get(ProcedureName.SIGN_CERTIFICATE),
+        BroadcastChannelProcedureName.SIGN_CERTIFICATE
+      )
+    })
+
+    await it('should map TRANSACTION_EVENT procedure to broadcast channel procedure', () => {
+      const mapping = getProcedureNameMapping()
+      assert.strictEqual(
+        mapping.get(ProcedureName.TRANSACTION_EVENT),
+        BroadcastChannelProcedureName.TRANSACTION_EVENT
+      )
+    })
+  })
+
+  // ==========================================================================
+  // Group 4: commandResponseToResponseStatus — 4 new command response cases (18 tests)
+  // ==========================================================================
+
+  await describe('commandResponseToResponseStatus OCPP 2.0.1 commands', async () => {
+    // -- SIGN_CERTIFICATE --
+
+    await it('should return SUCCESS for SIGN_CERTIFICATE with Accepted status', () => {
+      const { station } = createMockChargingStation({
+        connectorsCount: 1,
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      const status = testable.commandResponseToResponseStatus(
+        BroadcastChannelProcedureName.SIGN_CERTIFICATE,
+        { status: GenericStatus.Accepted }
+      )
+
+      assert.strictEqual(status, ResponseStatus.SUCCESS)
+    })
+
+    await it('should return FAILURE for SIGN_CERTIFICATE with Rejected status', () => {
+      const { station } = createMockChargingStation({
+        connectorsCount: 1,
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      const status = testable.commandResponseToResponseStatus(
+        BroadcastChannelProcedureName.SIGN_CERTIFICATE,
+        { status: GenericStatus.Rejected }
+      )
+
+      assert.strictEqual(status, ResponseStatus.FAILURE)
+    })
+
+    // -- GET_15118_EV_CERTIFICATE --
+
+    await it('should return SUCCESS for GET_15118_EV_CERTIFICATE with Accepted status', () => {
+      const { station } = createMockChargingStation({
+        connectorsCount: 1,
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      const status = testable.commandResponseToResponseStatus(
+        BroadcastChannelProcedureName.GET_15118_EV_CERTIFICATE,
+        { exiResponse: 'base64Data', status: Iso15118EVCertificateStatusEnumType.Accepted }
+      )
+
+      assert.strictEqual(status, ResponseStatus.SUCCESS)
+    })
+
+    await it('should return FAILURE for GET_15118_EV_CERTIFICATE with Failed status', () => {
+      const { station } = createMockChargingStation({
+        connectorsCount: 1,
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      const status = testable.commandResponseToResponseStatus(
+        BroadcastChannelProcedureName.GET_15118_EV_CERTIFICATE,
+        { exiResponse: 'base64Data', status: Iso15118EVCertificateStatusEnumType.Failed }
+      )
+
+      assert.strictEqual(status, ResponseStatus.FAILURE)
+    })
+
+    // -- GET_CERTIFICATE_STATUS --
+
+    await it('should return SUCCESS for GET_CERTIFICATE_STATUS with Accepted status', () => {
+      const { station } = createMockChargingStation({
+        connectorsCount: 1,
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      const status = testable.commandResponseToResponseStatus(
+        BroadcastChannelProcedureName.GET_CERTIFICATE_STATUS,
+        { status: GetCertificateStatusEnumType.Accepted }
+      )
+
+      assert.strictEqual(status, ResponseStatus.SUCCESS)
+    })
+
+    await it('should return FAILURE for GET_CERTIFICATE_STATUS with Failed status', () => {
+      const { station } = createMockChargingStation({
+        connectorsCount: 1,
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      const status = testable.commandResponseToResponseStatus(
+        BroadcastChannelProcedureName.GET_CERTIFICATE_STATUS,
+        { status: GetCertificateStatusEnumType.Failed }
+      )
+
+      assert.strictEqual(status, ResponseStatus.FAILURE)
+    })
+
+    // -- Fire-and-forget commands (empty response = SUCCESS) --
+
+    await it('should return SUCCESS for LOG_STATUS_NOTIFICATION with empty response', () => {
+      const { station } = createMockChargingStation({
+        connectorsCount: 1,
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      const status = testable.commandResponseToResponseStatus(
+        BroadcastChannelProcedureName.LOG_STATUS_NOTIFICATION,
+        {}
+      )
+
+      assert.strictEqual(status, ResponseStatus.SUCCESS)
+    })
+
+    await it('should return FAILURE for LOG_STATUS_NOTIFICATION with non-empty response', () => {
+      const { station } = createMockChargingStation({
+        connectorsCount: 1,
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      const status = testable.commandResponseToResponseStatus(
+        BroadcastChannelProcedureName.LOG_STATUS_NOTIFICATION,
+        { unexpected: 'field' }
+      )
+
+      assert.strictEqual(status, ResponseStatus.FAILURE)
+    })
+
+    await it('should return SUCCESS for NOTIFY_CUSTOMER_INFORMATION with empty response', () => {
+      const { station } = createMockChargingStation({
+        connectorsCount: 1,
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      const status = testable.commandResponseToResponseStatus(
+        BroadcastChannelProcedureName.NOTIFY_CUSTOMER_INFORMATION,
+        {}
+      )
+
+      assert.strictEqual(status, ResponseStatus.SUCCESS)
+    })
+
+    await it('should return FAILURE for NOTIFY_CUSTOMER_INFORMATION with non-empty response', () => {
+      const { station } = createMockChargingStation({
+        connectorsCount: 1,
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      const status = testable.commandResponseToResponseStatus(
+        BroadcastChannelProcedureName.NOTIFY_CUSTOMER_INFORMATION,
+        { unexpected: 'field' }
+      )
+
+      assert.strictEqual(status, ResponseStatus.FAILURE)
+    })
+
+    await it('should return SUCCESS for NOTIFY_REPORT with empty response', () => {
+      const { station } = createMockChargingStation({
+        connectorsCount: 1,
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      const status = testable.commandResponseToResponseStatus(
+        BroadcastChannelProcedureName.NOTIFY_REPORT,
+        {}
+      )
+
+      assert.strictEqual(status, ResponseStatus.SUCCESS)
+    })
+
+    await it('should return FAILURE for NOTIFY_REPORT with non-empty response', () => {
+      const { station } = createMockChargingStation({
+        connectorsCount: 1,
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      const status = testable.commandResponseToResponseStatus(
+        BroadcastChannelProcedureName.NOTIFY_REPORT,
+        { unexpected: 'field' }
+      )
+
+      assert.strictEqual(status, ResponseStatus.FAILURE)
+    })
+
+    await it('should return SUCCESS for SECURITY_EVENT_NOTIFICATION with empty response', () => {
+      const { station } = createMockChargingStation({
+        connectorsCount: 1,
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      const status = testable.commandResponseToResponseStatus(
+        BroadcastChannelProcedureName.SECURITY_EVENT_NOTIFICATION,
+        {}
+      )
+
+      assert.strictEqual(status, ResponseStatus.SUCCESS)
+    })
+
+    await it('should return FAILURE for SECURITY_EVENT_NOTIFICATION with non-empty response', () => {
+      const { station } = createMockChargingStation({
+        connectorsCount: 1,
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      const status = testable.commandResponseToResponseStatus(
+        BroadcastChannelProcedureName.SECURITY_EVENT_NOTIFICATION,
+        { unexpected: 'field' }
+      )
+
+      assert.strictEqual(status, ResponseStatus.FAILURE)
+    })
+
+    // -- TRANSACTION_EVENT --
+
+    await it('should return SUCCESS for TRANSACTION_EVENT with empty response', () => {
+      const { station } = createMockChargingStation({
+        connectorsCount: 1,
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      // isEmpty({}) returns true → SUCCESS
+      const status = testable.commandResponseToResponseStatus(
+        BroadcastChannelProcedureName.TRANSACTION_EVENT,
+        {}
+      )
+
+      assert.strictEqual(status, ResponseStatus.SUCCESS)
+    })
+
+    await it('should return SUCCESS for TRANSACTION_EVENT with no idTokenInfo', () => {
+      const { station } = createMockChargingStation({
+        connectorsCount: 1,
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      // idTokenInfo == null → SUCCESS
+      const status = testable.commandResponseToResponseStatus(
+        BroadcastChannelProcedureName.TRANSACTION_EVENT,
+        { chargingPriority: 1 }
+      )
+
+      assert.strictEqual(status, ResponseStatus.SUCCESS)
+    })
+
+    await it('should return SUCCESS for TRANSACTION_EVENT with Accepted idTokenInfo', () => {
+      const { station } = createMockChargingStation({
+        connectorsCount: 1,
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      const status = testable.commandResponseToResponseStatus(
+        BroadcastChannelProcedureName.TRANSACTION_EVENT,
+        { idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted } }
+      )
+
+      assert.strictEqual(status, ResponseStatus.SUCCESS)
+    })
+
+    await it('should return FAILURE for TRANSACTION_EVENT with Blocked idTokenInfo', () => {
+      const { station } = createMockChargingStation({
+        connectorsCount: 1,
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      const status = testable.commandResponseToResponseStatus(
+        BroadcastChannelProcedureName.TRANSACTION_EVENT,
+        { idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Blocked } }
+      )
+
+      assert.strictEqual(status, ResponseStatus.FAILURE)
+    })
+  })
+
+  // ==========================================================================
+  // Group 5: buildRequestPayload — OCPP 2.0.1 certificate passthrough (3 tests)
+  // ==========================================================================
+
+  await describe('buildRequestPayload OCPP 2.0.1 certificate passthrough', async () => {
+    await it('should build GET_15118_EV_CERTIFICATE payload as passthrough', () => {
+      const { station, testableRequestService } = createOCPP20RequestTestContext()
+      const commandParams = {
+        action: 'Install',
+        exiRequest: 'base64EncodedData',
+        iso15118SchemaVersion: 'urn:iso:15118:2:2013:MsgDef',
+      }
+
+      const payload = testableRequestService.buildRequestPayload(
+        station,
+        OCPP20RequestCommand.GET_15118_EV_CERTIFICATE,
+        commandParams
+      )
+
+      assert.deepStrictEqual(payload, commandParams)
+    })
+
+    await it('should build GET_CERTIFICATE_STATUS payload as passthrough', () => {
+      const { station, testableRequestService } = createOCPP20RequestTestContext()
+      const commandParams = {
+        ocspRequestData: {
+          hashAlgorithm: 'SHA256',
+          issuerKeyHash: 'abc123def456issuerkeyhash',
+          issuerNameHash: 'abc123def456issuernamehash',
+          responderURL: 'http://ocsp.example.com',
+          serialNumber: '1234567890',
+        },
+      }
+
+      const payload = testableRequestService.buildRequestPayload(
+        station,
+        OCPP20RequestCommand.GET_CERTIFICATE_STATUS,
+        commandParams
+      )
+
+      assert.deepStrictEqual(payload, commandParams)
+    })
+
+    await it('should build SIGN_CERTIFICATE payload as passthrough', () => {
+      const { station, testableRequestService } = createOCPP20RequestTestContext()
+      const commandParams = {
+        csr: '-----BEGIN CERTIFICATE REQUEST-----\nMIIBkTCB...\n-----END CERTIFICATE REQUEST-----',
+      }
+
+      const payload = testableRequestService.buildRequestPayload(
+        station,
+        OCPP20RequestCommand.SIGN_CERTIFICATE,
+        commandParams
+      )
+
+      assert.deepStrictEqual(payload, commandParams)
+    })
+  })
+
+  // ==========================================================================
+  // Group 6: commandHandler dispatch pipeline — verify full dispatch (8 tests)
+  // ==========================================================================
+
+  await describe('commandHandler OCPP 2.0.1 dispatch pipeline', async () => {
+    await it('should dispatch GET_15118_EV_CERTIFICATE through commandHandler', async () => {
+      const { sentRequests, station } = createMockStationWithRequestTracking()
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      await testable.commandHandler(BroadcastChannelProcedureName.GET_15118_EV_CERTIFICATE, {})
+
+      assert.strictEqual(sentRequests.length, 1)
+      assert.strictEqual(sentRequests[0].command, RequestCommand.GET_15118_EV_CERTIFICATE)
+    })
+
+    await it('should dispatch GET_CERTIFICATE_STATUS through commandHandler', async () => {
+      const { sentRequests, station } = createMockStationWithRequestTracking()
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      await testable.commandHandler(BroadcastChannelProcedureName.GET_CERTIFICATE_STATUS, {})
+
+      assert.strictEqual(sentRequests.length, 1)
+      assert.strictEqual(sentRequests[0].command, RequestCommand.GET_CERTIFICATE_STATUS)
+    })
+
+    await it('should dispatch LOG_STATUS_NOTIFICATION through commandHandler', async () => {
+      const { sentRequests, station } = createMockStationWithRequestTracking()
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      await testable.commandHandler(BroadcastChannelProcedureName.LOG_STATUS_NOTIFICATION, {})
+
+      assert.strictEqual(sentRequests.length, 1)
+      assert.strictEqual(sentRequests[0].command, RequestCommand.LOG_STATUS_NOTIFICATION)
+    })
+
+    await it('should dispatch NOTIFY_CUSTOMER_INFORMATION through commandHandler', async () => {
+      const { sentRequests, station } = createMockStationWithRequestTracking()
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      await testable.commandHandler(BroadcastChannelProcedureName.NOTIFY_CUSTOMER_INFORMATION, {})
+
+      assert.strictEqual(sentRequests.length, 1)
+      assert.strictEqual(sentRequests[0].command, RequestCommand.NOTIFY_CUSTOMER_INFORMATION)
+    })
+
+    await it('should dispatch NOTIFY_REPORT through commandHandler', async () => {
+      const { sentRequests, station } = createMockStationWithRequestTracking()
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      await testable.commandHandler(BroadcastChannelProcedureName.NOTIFY_REPORT, {})
+
+      assert.strictEqual(sentRequests.length, 1)
+      assert.strictEqual(sentRequests[0].command, RequestCommand.NOTIFY_REPORT)
+    })
+
+    await it('should dispatch SECURITY_EVENT_NOTIFICATION through commandHandler', async () => {
+      const { sentRequests, station } = createMockStationWithRequestTracking()
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      await testable.commandHandler(BroadcastChannelProcedureName.SECURITY_EVENT_NOTIFICATION, {})
+
+      assert.strictEqual(sentRequests.length, 1)
+      assert.strictEqual(sentRequests[0].command, RequestCommand.SECURITY_EVENT_NOTIFICATION)
+    })
+
+    await it('should dispatch SIGN_CERTIFICATE through commandHandler', async () => {
+      const { sentRequests, station } = createMockStationWithRequestTracking()
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      await testable.commandHandler(BroadcastChannelProcedureName.SIGN_CERTIFICATE, {})
+
+      assert.strictEqual(sentRequests.length, 1)
+      assert.strictEqual(sentRequests[0].command, RequestCommand.SIGN_CERTIFICATE)
+    })
+
+    await it('should dispatch TRANSACTION_EVENT through commandHandler', async () => {
+      const { sentRequests, station } = createMockStationWithRequestTracking()
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      await testable.commandHandler(BroadcastChannelProcedureName.TRANSACTION_EVENT, {})
+
+      assert.strictEqual(sentRequests.length, 1)
+      assert.strictEqual(sentRequests[0].command, RequestCommand.TRANSACTION_EVENT)
+    })
+  })
+
+  // ==========================================================================
+  // Group 7: requestHandler full pipeline — exercise handler dispatch via message events (6 tests)
+  // ==========================================================================
+
+  await describe('requestHandler full pipeline OCPP 2.0.1', async () => {
+    await it('should dispatch GET_15118_EV_CERTIFICATE via requestHandler', async () => {
+      const { sentRequests, station } = createMockStationWithRequestTracking()
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      testable.requestHandler({
+        data: [
+          randomUUID(),
+          BroadcastChannelProcedureName.GET_15118_EV_CERTIFICATE,
+          { hashIds: [station.stationInfo?.hashId] },
+        ],
+      })
+
+      await new Promise(resolve => {
+        setTimeout(resolve, 50)
+      })
+
+      assert.strictEqual(sentRequests.length, 1)
+      assert.strictEqual(sentRequests[0].command, RequestCommand.GET_15118_EV_CERTIFICATE)
+    })
+
+    await it('should dispatch LOG_STATUS_NOTIFICATION via requestHandler', async () => {
+      const { sentRequests, station } = createMockStationWithRequestTracking()
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      testable.requestHandler({
+        data: [
+          randomUUID(),
+          BroadcastChannelProcedureName.LOG_STATUS_NOTIFICATION,
+          { hashIds: [station.stationInfo?.hashId] },
+        ],
+      })
+
+      await new Promise(resolve => {
+        setTimeout(resolve, 50)
+      })
+
+      assert.strictEqual(sentRequests.length, 1)
+      assert.strictEqual(sentRequests[0].command, RequestCommand.LOG_STATUS_NOTIFICATION)
+    })
+
+    await it('should dispatch NOTIFY_CUSTOMER_INFORMATION via requestHandler', async () => {
+      const { sentRequests, station } = createMockStationWithRequestTracking()
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      testable.requestHandler({
+        data: [
+          randomUUID(),
+          BroadcastChannelProcedureName.NOTIFY_CUSTOMER_INFORMATION,
+          { hashIds: [station.stationInfo?.hashId] },
+        ],
+      })
+
+      await new Promise(resolve => {
+        setTimeout(resolve, 50)
+      })
+
+      assert.strictEqual(sentRequests.length, 1)
+      assert.strictEqual(sentRequests[0].command, RequestCommand.NOTIFY_CUSTOMER_INFORMATION)
+    })
+
+    await it('should dispatch NOTIFY_REPORT via requestHandler', async () => {
+      const { sentRequests, station } = createMockStationWithRequestTracking()
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      testable.requestHandler({
+        data: [
+          randomUUID(),
+          BroadcastChannelProcedureName.NOTIFY_REPORT,
+          { hashIds: [station.stationInfo?.hashId] },
+        ],
+      })
+
+      await new Promise(resolve => {
+        setTimeout(resolve, 50)
+      })
+
+      assert.strictEqual(sentRequests.length, 1)
+      assert.strictEqual(sentRequests[0].command, RequestCommand.NOTIFY_REPORT)
+    })
+
+    await it('should dispatch SECURITY_EVENT_NOTIFICATION via requestHandler', async () => {
+      const { sentRequests, station } = createMockStationWithRequestTracking()
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      testable.requestHandler({
+        data: [
+          randomUUID(),
+          BroadcastChannelProcedureName.SECURITY_EVENT_NOTIFICATION,
+          { hashIds: [station.stationInfo?.hashId] },
+        ],
+      })
+
+      await new Promise(resolve => {
+        setTimeout(resolve, 50)
+      })
+
+      assert.strictEqual(sentRequests.length, 1)
+      assert.strictEqual(sentRequests[0].command, RequestCommand.SECURITY_EVENT_NOTIFICATION)
+    })
+
+    await it('should dispatch METER_VALUES for OCPP 2.0.1 via requestHandler', async () => {
+      const { sentRequests, station } = createMockStationWithRequestTracking()
+
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+
+      testable.requestHandler({
+        data: [
+          randomUUID(),
+          BroadcastChannelProcedureName.METER_VALUES,
+          { hashIds: [station.stationInfo?.hashId] },
+        ],
+      })
+
+      await new Promise(resolve => {
+        setTimeout(resolve, 50)
+      })
+
+      assert.strictEqual(sentRequests.length, 1)
+      assert.strictEqual(sentRequests[0].command, RequestCommand.METER_VALUES)
+    })
+  })
+
+  await describe('mapping completeness', async () => {
+    const allProcedureNames = new Set(Object.values(ProcedureName))
+    const allBroadcastNames = new Set(Object.values(BroadcastChannelProcedureName))
+    const UI_ONLY_PROCEDURE_NAMES = new Set<string>([
+      ProcedureName.ADD_CHARGING_STATIONS,
+      ProcedureName.LIST_CHARGING_STATIONS,
+      ProcedureName.LIST_TEMPLATES,
+      ProcedureName.PERFORMANCE_STATISTICS,
+      ProcedureName.SIMULATOR_STATE,
+      ProcedureName.START_SIMULATOR,
+      ProcedureName.STOP_SIMULATOR,
+    ])
+
+    await it('should have a matching ProcedureName for every BroadcastChannelProcedureName', () => {
+      const missing = [...allBroadcastNames].filter(
+        name => !allProcedureNames.has(name as unknown as ProcedureName)
+      )
+      assert.deepStrictEqual(missing, [])
+    })
+
+    await it('should have a matching BroadcastChannelProcedureName for every non-UI-only ProcedureName', () => {
+      const missing = [...allProcedureNames].filter(
+        name =>
+          !UI_ONLY_PROCEDURE_NAMES.has(name) &&
+          !allBroadcastNames.has(name as unknown as BroadcastChannelProcedureName)
+      )
+      assert.deepStrictEqual(missing, [])
+    })
+
+    await it('should not have any ProcedureName classified as both UI-only and broadcast-capable', () => {
+      const overlap = [...UI_ONLY_PROCEDURE_NAMES].filter(name =>
+        allBroadcastNames.has(name as unknown as BroadcastChannelProcedureName)
+      )
+      assert.deepStrictEqual(overlap, [])
+    })
+
+    await it('should have a ProcedureNameToBroadCastChannelProcedureNameMapping entry for every BroadcastChannelProcedureName', () => {
+      const mapping = getProcedureNameMapping()
+      const mappedBroadcastNames = new Set(mapping.values())
+      const missing = [...allBroadcastNames].filter(name => !mappedBroadcastNames.has(name))
+      assert.deepStrictEqual(missing, [])
+    })
+
+    await it('should have a commandHandler for every BroadcastChannelProcedureName', () => {
+      const { station } = createMockChargingStation()
+      instance = new ChargingStationWorkerBroadcastChannel(station)
+      const testable = createTestableWorkerBroadcastChannel(instance)
+      const missing = [...allBroadcastNames].filter(name => !testable.commandHandlers.has(name))
+      assert.deepStrictEqual(missing, [])
+    })
+  })
+})