]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ocpp2): OCPP 2.0.1 Core certification readiness (#1712)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Thu, 12 Mar 2026 18:57:32 +0000 (19:57 +0100)
committerGitHub <noreply@github.com>
Thu, 12 Mar 2026 18:57:32 +0000 (19:57 +0100)
* feat(ocpp2): add type definitions and validator configs for OCPP 2.0.1 Core certification commands

* feat(ocpp2): implement DataTransfer reject handler

* feat(ocpp2): implement SetNetworkProfile reject handler

* feat(ocpp2): implement GetTransactionStatus handler

* feat(ocpp2): implement CustomerInformation clear handler

* feat(ocpp2): implement SecurityEventNotification outgoing command

* fix(ocpp2): handle TransactionEvent response idTokenInfo status

* feat(ocpp2): implement ChangeAvailability handler

* feat(ocpp2): implement standalone MeterValues outgoing command

* feat(ocpp2): implement UpdateFirmware and FirmwareStatusNotification

Add UpdateFirmware (CSMS→CS) incoming request handler with simulated
firmware update lifecycle state machine (Downloading → Downloaded →
SignatureVerified → Installing → Installed) and FirmwareStatusNotification
(CS→CSMS) outgoing command.

- Handle UPDATE_FIRMWARE incoming request, return Accepted status
- Simulate firmware update lifecycle via chained setTimeout calls
- Send FirmwareStatusNotification at each state transition
- Check firmware.signature presence for SignatureVerified state
- Add testable interfaces for both handler and request service
- Add 8 tests (5 for UpdateFirmware, 3 for FirmwareStatusNotification)

* feat(ocpp2): implement GetLog and LogStatusNotification

- Add GetLog incoming request handler with simulated upload lifecycle
  (Uploading → Uploaded via chained setTimeout)
- Add LogStatusNotification outgoing command in RequestService
- Register handleResponseLogStatusNotification in ResponseService
- Update testable interfaces with new handler and request method
- Add 4 GetLog tests (DiagnosticsLog, SecurityLog, requestId, retries)
- Add 3 LogStatusNotification tests (Uploading, requestId, empty response)
- All quality gates pass: lint, typecheck, build, 1737 tests

* feat(ocpp2): expand TriggerMessage handler with new trigger types

* docs: update README with OCPP 2.0.1 Core certification commands

* style(ocpp2): fix space-before-function-paren in TriggerMessage handler and test

* fix(ocpp2): add missing case branches in buildRequestPayload for new commands

buildRequestPayload throws NOT_SUPPORTED for FirmwareStatusNotification,
LogStatusNotification, MeterValues, NotifyCustomerInformation, and
SecurityEventNotification since they have no case branches. This causes
guaranteed runtime failures when TriggerMessage invokes requestHandler
for these commands.

Add pass-through case branches matching the existing pattern used by
other notification commands.

* fix(ocpp2): use zero-based seqNo in NotifyCustomerInformation per OCPP 2.0.1 spec

* fix(ocpp2): stop only specific transaction in handleResponseTransactionEvent

handleResponseTransactionEvent was stopping ALL active transactions when
any rejected idTokenInfo.status arrived. Per OCPP 2.0.1 spec (D01/D05),
only the specific transaction referenced by the TransactionEvent request
should be stopped.

Extract the transactionId from the request payload and use
getConnectorIdByTransactionId/getEvseIdByTransactionId to find and stop
only the affected transaction.

* fix(ocpp2): set idle EVSEs Inoperative immediately on CS-level ChangeAvailability per G03.FR.04

* fix(ocpp2): add firmware lifecycle delay, JSDoc, and consistent handler patterns

- Add delay between Downloaded and SignatureVerified in firmware update lifecycle per J01
- Add missing JSDoc for requestLogStatusNotification in testable interface
- Convert 4 arrow function handlers to regular methods for consistency with pre-existing handlers

* refactor(ocpp2): resolve SonarCloud quality gate findings

- Replace duplicated delay() functions with shared sleep() utility
- Extract handleEvseChangeAvailability and handleCsLevelInoperative to
  reduce cognitive complexity of handleRequestChangeAvailability
- Extract hasAnyActiveTransaction to eliminate nested loops
- Fix negated conditions and nested template literals

* refactor(ocpp2): deduplicate validator configs via shared schema name maps

- Extract incomingRequestSchemaNames and outgoingRequestSchemaNames as
  single source of truth for command-to-schema mappings
- Generate request/response configs from shared maps, eliminating 96
  lines of structural duplication flagged by SonarCloud
- Fix remaining negated conditions in ternary expressions

* fix(test): deduplicate MeterValues call, add multi-EVSE isolation test

* fix(ocpp2): eliminate double status notification, document messagesInQueue

* test(ocpp2): align test files with TEST_STYLE_GUIDE conventions

* fix: comply with E14.FR.06 and harmonize statusInfo

26 files changed:
README.md
eslint.config.js
src/charging-station/ocpp/2.0/OCPP20Constants.ts
src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20RequestService.ts
src/charging-station/ocpp/2.0/OCPP20ResponseService.ts
src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts
src/charging-station/ocpp/2.0/__testable__/OCPP20RequestServiceTestable.ts
src/charging-station/ocpp/2.0/__testable__/index.ts
src/types/index.ts
src/types/ocpp/2.0/Common.ts
src/types/ocpp/2.0/Requests.ts
src/types/ocpp/2.0/Responses.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ChangeAvailability.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CustomerInformation.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-DataTransfer.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetTransactionStatus.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-SetNetworkProfile.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20RequestService-FirmwareStatusNotification.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20RequestService-LogStatusNotification.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20RequestService-MeterValues.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20RequestService-SecurityEventNotification.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts

index 578121e076ef22275536a7b84a63243ff41f8136..3549a5bfac9a6a13e92a1bf1fbb026bfb01bee8f 100644 (file)
--- a/README.md
+++ b/README.md
@@ -492,7 +492,11 @@ make SUBMODULES_INIT=true
 
 ### Version 2.0.x
 
-> **Note**: OCPP 2.0.x implementation is **partial** and under active development.
+> **Note**: OCPP 2.0.x Core profile mandatory commands are now implemented.
+
+#### A. Security
+
+- :white_check_mark: SecurityEventNotification
 
 #### B. Provisioning
 
@@ -500,6 +504,7 @@ make SUBMODULES_INIT=true
 - :white_check_mark: GetBaseReport
 - :white_check_mark: GetVariables
 - :white_check_mark: NotifyReport
+- :white_check_mark: SetNetworkProfile
 - :white_check_mark: SetVariables
 
 #### C. Authorization
@@ -513,6 +518,7 @@ make SUBMODULES_INIT=true
 
 #### E. Transactions
 
+- :white_check_mark: GetTransactionStatus
 - :white_check_mark: RequestStartTransaction
 - :white_check_mark: RequestStopTransaction
 - :white_check_mark: TransactionEvent
@@ -525,13 +531,20 @@ make SUBMODULES_INIT=true
 
 #### G. Availability
 
+- :white_check_mark: ChangeAvailability
 - :white_check_mark: Heartbeat
+- :white_check_mark: MeterValues
 - :white_check_mark: StatusNotification
 
+#### J. Diagnostics
+
+- :white_check_mark: GetLog
+- :white_check_mark: LogStatusNotification
+
 #### L. FirmwareManagement
 
-- :x: UpdateFirmware
-- :x: FirmwareStatusNotification
+- :white_check_mark: FirmwareStatusNotification
+- :white_check_mark: UpdateFirmware
 
 #### M. ISO 15118 CertificateManagement
 
@@ -548,9 +561,13 @@ make SUBMODULES_INIT=true
 > - **Mock CSR generation**: The `SignCertificate` command generates a mock Certificate Signing Request (CSR) for simulation purposes. In production, this should be replaced with actual cryptographic CSR generation.
 > - **OCSP stub**: Online Certificate Status Protocol (OCSP) validation is stubbed and returns `Failed` status. Full OCSP integration requires external OCSP responder configuration.
 
+#### N. CustomerInformation
+
+- :white_check_mark: CustomerInformation
+
 #### P. DataTransfer
 
-- :x: DataTransfer
+- :white_check_mark: DataTransfer
 
 ## OCPP-J standard parameters supported
 
index e73394864c9f432d2da950f782e7549fb04d74c0..3ce166cf11c9c0d7673c204a5e935576cead1b18 100644 (file)
@@ -89,6 +89,8 @@ export default defineConfig([
               'CALLERROR',
               'CALLRESULTERROR',
               'reservability',
+              // VPN protocol acronyms
+              'PPTP',
             ],
           },
         },
index 636932c4b9a84752db3e97be5db4a5b33d4fb233..cf539d156b283049891437cb0c8d6df6b5613c9d 100644 (file)
@@ -1,5 +1,6 @@
 import {
   type ConnectorStatusTransition,
+  MessageTriggerEnumType,
   OCPP20ConnectorStatusEnumType,
   OCPP20TriggerReasonEnumType,
 } from '../../../types/index.js'
@@ -143,6 +144,19 @@ export class OCPP20Constants extends OCPPConstants {
    */
   static readonly HANDLER_TIMEOUT_MS = 30_000
 
+  /**
+   * Set of MessageTriggerEnumType values that the charging station supports
+   * in the TriggerMessage handler. Used for validation and capability reporting.
+   */
+  static readonly SupportedTriggerMessages: ReadonlySet<MessageTriggerEnumType> = new Set([
+    MessageTriggerEnumType.BootNotification,
+    MessageTriggerEnumType.FirmwareStatusNotification,
+    MessageTriggerEnumType.Heartbeat,
+    MessageTriggerEnumType.LogStatusNotification,
+    MessageTriggerEnumType.MeterValues,
+    MessageTriggerEnumType.StatusNotification,
+  ])
+
   static readonly TriggerReasonMapping: readonly TriggerReasonMap[] = Object.freeze([
     // Priority 1: Remote Commands (highest priority)
     {
index e01c50bea41522ee47795ccfc0114274f37e416f..3b01501e18736b411ae943e198315da375e7ba3d 100644 (file)
@@ -15,13 +15,17 @@ import { OCPPError } from '../../../exception/index.js'
 import {
   AttributeEnumType,
   CertificateSigningUseEnumType,
+  ChangeAvailabilityStatusEnumType,
   ConnectorEnumType,
   ConnectorStatusEnum,
+  CustomerInformationStatusEnumType,
   DataEnumType,
+  DataTransferStatusEnumType,
   DeleteCertificateStatusEnumType,
   ErrorType,
   type EvseStatus,
   FirmwareStatus,
+  FirmwareStatusEnumType,
   GenericDeviceModelStatusEnumType,
   GenericStatus,
   GetCertificateIdUseEnumType,
@@ -31,21 +35,34 @@ import {
   InstallCertificateStatusEnumType,
   InstallCertificateUseEnumType,
   type JsonType,
+  LogStatusEnumType,
   MessageTriggerEnumType,
   type OCPP20BootNotificationRequest,
   type OCPP20BootNotificationResponse,
   type OCPP20CertificateSignedRequest,
   type OCPP20CertificateSignedResponse,
+  type OCPP20ChangeAvailabilityRequest,
+  type OCPP20ChangeAvailabilityResponse,
   type OCPP20ClearCacheResponse,
   OCPP20ComponentName,
   OCPP20ConnectorStatusEnumType,
+  type OCPP20CustomerInformationRequest,
+  type OCPP20CustomerInformationResponse,
+  type OCPP20DataTransferRequest,
+  type OCPP20DataTransferResponse,
   type OCPP20DeleteCertificateRequest,
   type OCPP20DeleteCertificateResponse,
   OCPP20DeviceInfoVariableName,
+  type OCPP20FirmwareStatusNotificationRequest,
+  type OCPP20FirmwareStatusNotificationResponse,
   type OCPP20GetBaseReportRequest,
   type OCPP20GetBaseReportResponse,
   type OCPP20GetInstalledCertificateIdsRequest,
   type OCPP20GetInstalledCertificateIdsResponse,
+  type OCPP20GetLogRequest,
+  type OCPP20GetLogResponse,
+  type OCPP20GetTransactionStatusRequest,
+  type OCPP20GetTransactionStatusResponse,
   type OCPP20GetVariablesRequest,
   type OCPP20GetVariablesResponse,
   type OCPP20HeartbeatRequest,
@@ -53,8 +70,16 @@ import {
   OCPP20IncomingRequestCommand,
   type OCPP20InstallCertificateRequest,
   type OCPP20InstallCertificateResponse,
+  type OCPP20LogStatusNotificationRequest,
+  type OCPP20LogStatusNotificationResponse,
+  OCPP20MeasurandEnumType,
+  type OCPP20MeterValuesRequest,
+  type OCPP20MeterValuesResponse,
+  type OCPP20NotifyCustomerInformationRequest,
+  type OCPP20NotifyCustomerInformationResponse,
   type OCPP20NotifyReportRequest,
   type OCPP20NotifyReportResponse,
+  OCPP20ReadingContextEnumType,
   OCPP20RequestCommand,
   type OCPP20RequestStartTransactionRequest,
   type OCPP20RequestStartTransactionResponse,
@@ -63,6 +88,8 @@ import {
   OCPP20RequiredVariableName,
   type OCPP20ResetRequest,
   type OCPP20ResetResponse,
+  type OCPP20SetNetworkProfileRequest,
+  type OCPP20SetNetworkProfileResponse,
   type OCPP20SetVariablesRequest,
   type OCPP20SetVariablesResponse,
   type OCPP20StatusNotificationRequest,
@@ -71,7 +98,10 @@ import {
   type OCPP20TriggerMessageResponse,
   type OCPP20UnlockConnectorRequest,
   type OCPP20UnlockConnectorResponse,
+  type OCPP20UpdateFirmwareRequest,
+  type OCPP20UpdateFirmwareResponse,
   OCPPVersion,
+  OperationalStatusEnumType,
   ReasonCodeEnumType,
   RegistrationStatusEnumType,
   ReportBaseEnumType,
@@ -79,10 +109,13 @@ import {
   RequestStartStopStatusEnumType,
   ResetEnumType,
   ResetStatusEnumType,
+  SetNetworkProfileStatusEnumType,
   SetVariableStatusEnumType,
   StopTransactionReason,
   TriggerMessageStatusEnumType,
   UnlockStatusEnumType,
+  UpdateFirmwareStatusEnumType,
+  UploadLogStatusEnumType,
 } from '../../../types/index.js'
 import {
   OCPP20ChargingProfileKindEnumType,
@@ -95,6 +128,7 @@ import {
   generateUUID,
   isAsyncFunction,
   logger,
+  sleep,
   validateUUID,
 } from '../../../utils/index.js'
 import {
@@ -136,10 +170,22 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         OCPP20IncomingRequestCommand.CERTIFICATE_SIGNED,
         this.toHandler(this.handleRequestCertificateSigned.bind(this)),
       ],
+      [
+        OCPP20IncomingRequestCommand.CHANGE_AVAILABILITY,
+        this.toHandler(this.handleRequestChangeAvailability.bind(this)),
+      ],
       [
         OCPP20IncomingRequestCommand.CLEAR_CACHE,
         this.toHandler(this.handleRequestClearCache.bind(this)),
       ],
+      [
+        OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION,
+        this.toHandler(this.handleRequestCustomerInformation.bind(this)),
+      ],
+      [
+        OCPP20IncomingRequestCommand.DATA_TRANSFER,
+        this.toHandler(this.handleRequestDataTransfer.bind(this)),
+      ],
       [
         OCPP20IncomingRequestCommand.DELETE_CERTIFICATE,
         this.toHandler(this.handleRequestDeleteCertificate.bind(this)),
@@ -152,6 +198,11 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         OCPP20IncomingRequestCommand.GET_INSTALLED_CERTIFICATE_IDS,
         this.toHandler(this.handleRequestGetInstalledCertificateIds.bind(this)),
       ],
+      [OCPP20IncomingRequestCommand.GET_LOG, this.toHandler(this.handleRequestGetLog.bind(this))],
+      [
+        OCPP20IncomingRequestCommand.GET_TRANSACTION_STATUS,
+        this.toHandler(this.handleRequestGetTransactionStatus.bind(this)),
+      ],
       [
         OCPP20IncomingRequestCommand.GET_VARIABLES,
         this.toHandler(this.handleRequestGetVariables.bind(this)),
@@ -169,6 +220,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         this.toHandler(this.handleRequestStopTransaction.bind(this)),
       ],
       [OCPP20IncomingRequestCommand.RESET, this.toHandler(this.handleRequestReset.bind(this))],
+      [
+        OCPP20IncomingRequestCommand.SET_NETWORK_PROFILE,
+        this.toHandler(this.handleRequestSetNetworkProfile.bind(this)),
+      ],
       [
         OCPP20IncomingRequestCommand.SET_VARIABLES,
         this.toHandler(this.handleRequestSetVariables.bind(this)),
@@ -181,6 +236,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         OCPP20IncomingRequestCommand.UNLOCK_CONNECTOR,
         this.toHandler(this.handleRequestUnlockConnector.bind(this)),
       ],
+      [
+        OCPP20IncomingRequestCommand.UPDATE_FIRMWARE,
+        this.toHandler(this.handleRequestUpdateFirmware.bind(this)),
+      ],
     ])
     this.payloadValidatorFunctions = OCPP20ServiceUtils.createPayloadValidatorMap(
       OCPP20ServiceUtils.createIncomingRequestPayloadConfigs(),
@@ -859,6 +918,90 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     return secondsToMilliseconds(Constants.DEFAULT_TX_UPDATED_INTERVAL)
   }
 
+  private handleCsLevelInoperative (
+    chargingStation: ChargingStation,
+    operationalStatus: OperationalStatusEnumType,
+    newConnectorStatus: OCPP20ConnectorStatusEnumType
+  ): OCPP20ChangeAvailabilityResponse | undefined {
+    let hasActiveTransactions = false
+    for (const [evseId, evseStatus] of chargingStation.evses) {
+      if (evseId === 0) {
+        continue
+      }
+      if (this.hasEvseActiveTransactions(evseStatus)) {
+        hasActiveTransactions = true
+        logger.info(
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestChangeAvailability: EVSE ${evseId.toString()} has active transaction, will be set Inoperative when transaction ends`
+        )
+      } else {
+        evseStatus.availability = operationalStatus
+        logger.info(
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestChangeAvailability: EVSE ${evseId.toString()} set to ${operationalStatus} immediately (idle)`
+        )
+      }
+    }
+    if (hasActiveTransactions) {
+      for (const [evseId, evseStatus] of chargingStation.evses) {
+        if (evseId > 0 && !this.hasEvseActiveTransactions(evseStatus)) {
+          this.sendEvseStatusNotifications(chargingStation, evseId, newConnectorStatus)
+        }
+      }
+      logger.info(
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestChangeAvailability: Charging station partially set to ${operationalStatus}, some EVSEs scheduled`
+      )
+      return {
+        status: ChangeAvailabilityStatusEnumType.Scheduled,
+      }
+    }
+    return undefined
+  }
+
+  private handleEvseChangeAvailability (
+    chargingStation: ChargingStation,
+    evseId: number,
+    operationalStatus: OperationalStatusEnumType,
+    newConnectorStatus: OCPP20ConnectorStatusEnumType
+  ): OCPP20ChangeAvailabilityResponse {
+    if (!chargingStation.evses.has(evseId)) {
+      logger.warn(
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestChangeAvailability: EVSE ${evseId.toString()} not found, rejecting`
+      )
+      return {
+        status: ChangeAvailabilityStatusEnumType.Rejected,
+        statusInfo: {
+          additionalInfo: `EVSE ${evseId.toString()} does not exist on charging station`,
+          reasonCode: ReasonCodeEnumType.UnknownEvse,
+        },
+      }
+    }
+
+    const evseStatus = chargingStation.getEvseStatus(evseId)
+    if (
+      evseStatus != null &&
+      operationalStatus === OperationalStatusEnumType.Inoperative &&
+      this.hasEvseActiveTransactions(evseStatus)
+    ) {
+      logger.info(
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestChangeAvailability: EVSE ${evseId.toString()} has active transaction, scheduling availability change`
+      )
+      return {
+        status: ChangeAvailabilityStatusEnumType.Scheduled,
+      }
+    }
+
+    if (evseStatus != null) {
+      evseStatus.availability = operationalStatus
+    }
+    this.sendEvseStatusNotifications(chargingStation, evseId, newConnectorStatus)
+
+    logger.info(
+      `${chargingStation.logPrefix()} ${moduleName}.handleRequestChangeAvailability: EVSE ${evseId.toString()} set to ${operationalStatus}`
+    )
+    return {
+      status: ChangeAvailabilityStatusEnumType.Accepted,
+    }
+  }
+
   /**
    * Handles OCPP 2.0 CertificateSigned request from central system
    * Receives signed certificate chain from CSMS and stores it in the charging station
@@ -953,6 +1096,145 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     }
   }
 
+  /**
+   * Handles OCPP 2.0.1 ChangeAvailability request from central system (F03, F04).
+   * Changes the operational status of the entire charging station or a specific EVSE.
+   * Per G03.FR.01: EVSE level without ongoing transaction → Accepted
+   * Per G03.FR.02: CS level without ongoing transaction → Accepted
+   * Per G03.FR.03: EVSE level with ongoing transaction and Inoperative → Scheduled
+   * Per G03.FR.04: CS level with some EVSEs having transactions and Inoperative → Scheduled
+   * @param chargingStation - The charging station instance processing the request
+   * @param commandPayload - ChangeAvailability request payload with operationalStatus and optional evse
+   * @returns ChangeAvailabilityResponse with Accepted, Rejected, or Scheduled
+   */
+  private handleRequestChangeAvailability (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20ChangeAvailabilityRequest
+  ): OCPP20ChangeAvailabilityResponse {
+    const { evse, operationalStatus } = commandPayload
+    const evseIdLabel = evse?.id == null ? '' : ` for EVSE ${evse.id.toString()}`
+
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.handleRequestChangeAvailability: Received ChangeAvailability request with operationalStatus=${operationalStatus}${evseIdLabel}`
+    )
+
+    const newConnectorStatus =
+      operationalStatus === OperationalStatusEnumType.Inoperative
+        ? OCPP20ConnectorStatusEnumType.Unavailable
+        : OCPP20ConnectorStatusEnumType.Available
+
+    // EVSE-level change
+    if (evse?.id != null && evse.id > 0) {
+      return this.handleEvseChangeAvailability(
+        chargingStation,
+        evse.id,
+        operationalStatus,
+        newConnectorStatus
+      )
+    }
+
+    // CS-level change (no evse or evse.id === 0)
+    if (operationalStatus === OperationalStatusEnumType.Inoperative) {
+      const result = this.handleCsLevelInoperative(
+        chargingStation,
+        operationalStatus,
+        newConnectorStatus
+      )
+      if (result != null) {
+        return result
+      }
+    }
+
+    // Apply availability change to all EVSEs (for Operative, or Inoperative with no active transactions)
+    for (const [evseId, evseStatus] of chargingStation.evses) {
+      if (evseId > 0) {
+        evseStatus.availability = operationalStatus
+      }
+    }
+    this.sendAllConnectorsStatusNotifications(chargingStation, newConnectorStatus)
+
+    logger.info(
+      `${chargingStation.logPrefix()} ${moduleName}.handleRequestChangeAvailability: Charging station set to ${operationalStatus}`
+    )
+    return {
+      status: ChangeAvailabilityStatusEnumType.Accepted,
+    }
+  }
+
+  /**
+   * Handles OCPP 2.0.1 CustomerInformation request from central system.
+   * Per TC_N_32_CS: CS must respond to CustomerInformation with Accepted for clear requests.
+   * Simulator has no persistent customer data, so clear is accepted but no-op.
+   * For report requests, sends empty NotifyCustomerInformation (simulator has no real data).
+   * @param chargingStation - The charging station instance processing the request
+   * @param commandPayload - CustomerInformation request payload with clear/report flags
+   * @returns CustomerInformationResponse with status
+   */
+  private handleRequestCustomerInformation (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20CustomerInformationRequest
+  ): OCPP20CustomerInformationResponse {
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.handleRequestCustomerInformation: Received CustomerInformation request with clear=${commandPayload.clear.toString()}, report=${commandPayload.report.toString()}`
+    )
+
+    if (commandPayload.clear) {
+      logger.info(
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestCustomerInformation: Clear request accepted (simulator has no persistent customer data)`
+      )
+      return {
+        status: CustomerInformationStatusEnumType.Accepted,
+      }
+    }
+
+    if (commandPayload.report) {
+      logger.info(
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestCustomerInformation: Report request accepted, sending empty NotifyCustomerInformation`
+      )
+      // Fire-and-forget NotifyCustomerInformation with empty data
+      setImmediate(() => {
+        this.sendNotifyCustomerInformation(chargingStation, commandPayload.requestId).catch(
+          (error: unknown) => {
+            logger.error(
+              `${chargingStation.logPrefix()} ${moduleName}.handleRequestCustomerInformation: Error sending NotifyCustomerInformation:`,
+              error
+            )
+          }
+        )
+      })
+      return {
+        status: CustomerInformationStatusEnumType.Accepted,
+      }
+    }
+
+    logger.warn(
+      `${chargingStation.logPrefix()} ${moduleName}.handleRequestCustomerInformation: Neither clear nor report flag set, rejecting`
+    )
+    return {
+      status: CustomerInformationStatusEnumType.Rejected,
+    }
+  }
+
+  /**
+   * Handles OCPP 2.0.1 DataTransfer request
+   * Per TC_P_01_CS: CS with no vendor extensions must respond UnknownVendorId
+   * @param chargingStation - The charging station instance
+   * @param commandPayload - The DataTransfer request payload
+   * @returns DataTransferResponse with UnknownVendorId status
+   */
+  private handleRequestDataTransfer (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20DataTransferRequest
+  ): OCPP20DataTransferResponse {
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.handleRequestDataTransfer: Received DataTransfer request with vendorId '${commandPayload.vendorId}'`
+    )
+    // Per TC_P_01_CS: CS with no vendor extensions must respond UnknownVendorId
+    return {
+      status: DataTransferStatusEnumType.UnknownVendorId,
+    }
+  }
+
   /**
    * Handles OCPP 2.0 DeleteCertificate request from central system
    * Deletes a certificate matching the provided hash data from the charging station
@@ -1131,6 +1413,76 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     }
   }
 
+  /**
+   * Handles OCPP 2.0.1 GetLog request from central system.
+   * Accepts the log upload request and simulates the log upload lifecycle
+   * by sending LogStatusNotification messages through a state machine:
+   * Uploading → Uploaded
+   * @param chargingStation - The charging station instance processing the request
+   * @param commandPayload - GetLog request payload with log type, requestId, and log parameters
+   * @returns GetLogResponse with Accepted status and simulated filename
+   */
+  private handleRequestGetLog (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20GetLogRequest
+  ): OCPP20GetLogResponse {
+    const { logType, requestId } = commandPayload
+
+    logger.info(
+      `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetLog: Received GetLog request with requestId ${requestId.toString()} for logType '${logType}'`
+    )
+
+    // Fire-and-forget log upload state machine after response is returned
+    setImmediate(() => {
+      this.simulateLogUploadLifecycle(chargingStation, requestId).catch((error: unknown) => {
+        logger.error(
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetLog: Error during log upload simulation:`,
+          error
+        )
+      })
+    })
+
+    return {
+      filename: 'simulator-log.txt',
+      status: LogStatusEnumType.Accepted,
+    }
+  }
+
+  /**
+   * Handles OCPP 2.0.1 GetTransactionStatus request from central system.
+   * Per D14, E28-E34: Returns transaction status with ongoingIndicator and messagesInQueue.
+   * @param chargingStation - The charging station instance processing the request
+   * @param commandPayload - GetTransactionStatus request payload with optional transactionId
+   * @returns GetTransactionStatusResponse with ongoingIndicator and messagesInQueue
+   */
+  private handleRequestGetTransactionStatus (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20GetTransactionStatusRequest
+  ): OCPP20GetTransactionStatusResponse {
+    const { transactionId } = commandPayload
+    const transactionLabel = transactionId == null ? '' : ` for transaction ID ${transactionId}`
+
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetTransactionStatus: Received GetTransactionStatus request${transactionLabel}`
+    )
+
+    // E14.FR.06: When transactionId is omitted, ongoingIndicator SHALL NOT be set
+    if (transactionId == null) {
+      return {
+        // Simulator has no persistent offline message buffer
+        messagesInQueue: false,
+      }
+    }
+
+    const evseId = chargingStation.getEvseIdByTransactionId(transactionId)
+
+    return {
+      // Simulator has no persistent offline message buffer
+      messagesInQueue: false,
+      ongoingIndicator: evseId != null,
+    }
+  }
+
   private async handleRequestInstallCertificate (
     chargingStation: ChargingStation,
     commandPayload: OCPP20InstallCertificateRequest
@@ -1430,6 +1782,31 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     }
   }
 
+  /**
+   * Handles OCPP 2.0.1 SetNetworkProfile request from central system
+   * Per TC_B_43_CS: CS must respond to SetNetworkProfile at minimum with Rejected
+   * The simulator does not support network profile switching
+   * @param chargingStation - The charging station instance
+   * @param commandPayload - The SetNetworkProfile request payload
+   * @returns SetNetworkProfileResponse with Rejected status
+   */
+  private handleRequestSetNetworkProfile (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20SetNetworkProfileRequest
+  ): OCPP20SetNetworkProfileResponse {
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.handleRequestSetNetworkProfile: Received SetNetworkProfile request`
+    )
+    // Per TC_B_43_CS: CS must respond to SetNetworkProfile at minimum with Rejected
+    return {
+      status: SetNetworkProfileStatusEnumType.Rejected,
+      statusInfo: {
+        additionalInfo: 'Simulator does not support network profile configuration',
+        reasonCode: ReasonCodeEnumType.UnsupportedRequest,
+      },
+    }
+  }
+
   /**
    * Handles OCPP 2.0 RequestStartTransaction request from central system
    * Initiates charging transaction on specified EVSE with enhanced authorization
@@ -1782,6 +2159,27 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
             })
           return { status: TriggerMessageStatusEnumType.Accepted }
 
+        case MessageTriggerEnumType.FirmwareStatusNotification:
+          chargingStation.ocppRequestService
+            .requestHandler<
+              OCPP20FirmwareStatusNotificationRequest,
+              OCPP20FirmwareStatusNotificationResponse
+            >(
+              chargingStation,
+              OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION,
+              {
+                status: FirmwareStatusEnumType.Idle,
+              },
+              { skipBufferingOnError: true, triggerMessage: true }
+            )
+            .catch((error: unknown) => {
+              logger.error(
+                `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending FirmwareStatusNotification:`,
+                error
+              )
+            })
+          return { status: TriggerMessageStatusEnumType.Accepted }
+
         case MessageTriggerEnumType.Heartbeat:
           chargingStation.ocppRequestService
             .requestHandler<
@@ -1796,6 +2194,59 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
             })
           return { status: TriggerMessageStatusEnumType.Accepted }
 
+        case MessageTriggerEnumType.LogStatusNotification:
+          chargingStation.ocppRequestService
+            .requestHandler<
+              OCPP20LogStatusNotificationRequest,
+              OCPP20LogStatusNotificationResponse
+            >(
+              chargingStation,
+              OCPP20RequestCommand.LOG_STATUS_NOTIFICATION,
+              {
+                status: UploadLogStatusEnumType.Idle,
+              },
+              { skipBufferingOnError: true, triggerMessage: true }
+            )
+            .catch((error: unknown) => {
+              logger.error(
+                `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending LogStatusNotification:`,
+                error
+              )
+            })
+          return { status: TriggerMessageStatusEnumType.Accepted }
+
+        case MessageTriggerEnumType.MeterValues: {
+          const evseId = evse?.id ?? 0
+          chargingStation.ocppRequestService
+            .requestHandler<OCPP20MeterValuesRequest, OCPP20MeterValuesResponse>(
+              chargingStation,
+              OCPP20RequestCommand.METER_VALUES,
+              {
+                evseId,
+                meterValue: [
+                  {
+                    sampledValue: [
+                      {
+                        context: OCPP20ReadingContextEnumType.TRIGGER,
+                        measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+                        value: 0,
+                      },
+                    ],
+                    timestamp: new Date(),
+                  },
+                ],
+              },
+              { skipBufferingOnError: true, triggerMessage: true }
+            )
+            .catch((error: unknown) => {
+              logger.error(
+                `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending MeterValues:`,
+                error
+              )
+            })
+          return { status: TriggerMessageStatusEnumType.Accepted }
+        }
+
         case MessageTriggerEnumType.StatusNotification:
           if (evse?.id !== undefined && evse.id > 0 && evse.connectorId !== undefined) {
             const evseStatus = chargingStation.evses.get(evse.id)
@@ -1972,6 +2423,42 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     }
   }
 
+  /**
+   * Handles OCPP 2.0.1 UpdateFirmware request from central system.
+   * Accepts the firmware update request and simulates the firmware update lifecycle
+   * by sending FirmwareStatusNotification messages through a state machine:
+   * Downloading → Downloaded → [SignatureVerified] → Installing → Installed
+   * @param chargingStation - The charging station instance processing the request
+   * @param commandPayload - UpdateFirmware request payload with firmware details and requestId
+   * @returns UpdateFirmwareResponse with Accepted status
+   */
+  private handleRequestUpdateFirmware (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20UpdateFirmwareRequest
+  ): OCPP20UpdateFirmwareResponse {
+    const { firmware, requestId } = commandPayload
+
+    logger.info(
+      `${chargingStation.logPrefix()} ${moduleName}.handleRequestUpdateFirmware: Received UpdateFirmware request with requestId ${requestId.toString()} for location '${firmware.location}'`
+    )
+
+    // Fire-and-forget firmware update state machine after response is returned
+    setImmediate(() => {
+      this.simulateFirmwareUpdateLifecycle(chargingStation, requestId, firmware.signature).catch(
+        (error: unknown) => {
+          logger.error(
+            `${chargingStation.logPrefix()} ${moduleName}.handleRequestUpdateFirmware: Error during firmware update simulation:`,
+            error
+          )
+        }
+      )
+    })
+
+    return {
+      status: UpdateFirmwareStatusEnumType.Accepted,
+    }
+  }
+
   /**
    * Checks if a specific EVSE has any active transactions.
    * @param evse - The EVSE to check
@@ -2291,6 +2778,55 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     }
   }
 
+  private sendFirmwareStatusNotification (
+    chargingStation: ChargingStation,
+    status: FirmwareStatusEnumType,
+    requestId: number
+  ): Promise<OCPP20FirmwareStatusNotificationResponse> {
+    return chargingStation.ocppRequestService.requestHandler<
+      OCPP20FirmwareStatusNotificationRequest,
+      OCPP20FirmwareStatusNotificationResponse
+    >(chargingStation, OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION, {
+      requestId,
+      status,
+    })
+  }
+
+  private sendLogStatusNotification (
+    chargingStation: ChargingStation,
+    status: UploadLogStatusEnumType,
+    requestId: number
+  ): Promise<OCPP20LogStatusNotificationResponse> {
+    return chargingStation.ocppRequestService.requestHandler<
+      OCPP20LogStatusNotificationRequest,
+      OCPP20LogStatusNotificationResponse
+    >(chargingStation, OCPP20RequestCommand.LOG_STATUS_NOTIFICATION, {
+      requestId,
+      status,
+    })
+  }
+
+  private async sendNotifyCustomerInformation (
+    chargingStation: ChargingStation,
+    requestId: number
+  ): Promise<void> {
+    const notifyCustomerInformationRequest: OCPP20NotifyCustomerInformationRequest = {
+      data: '',
+      generatedAt: new Date(),
+      requestId,
+      seqNo: 0,
+      tbc: false,
+    }
+    await chargingStation.ocppRequestService.requestHandler<
+      OCPP20NotifyCustomerInformationRequest,
+      OCPP20NotifyCustomerInformationResponse
+    >(
+      chargingStation,
+      OCPP20RequestCommand.NOTIFY_CUSTOMER_INFORMATION,
+      notifyCustomerInformationRequest
+    )
+  }
+
   private async sendNotifyReportRequest (
     chargingStation: ChargingStation,
     request: OCPP20GetBaseReportRequest,
@@ -2340,6 +2876,87 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     this.reportDataCache.delete(requestId)
   }
 
+  /**
+   * Simulates a firmware update lifecycle through status progression using chained setTimeout calls.
+   * Sequence: Downloading → Downloaded → [SignatureVerified if signature present] → Installing → Installed
+   * @param chargingStation - The charging station instance
+   * @param requestId - The request ID from the UpdateFirmware request
+   * @param signature - Optional firmware signature; triggers SignatureVerified step if present
+   */
+  private async simulateFirmwareUpdateLifecycle (
+    chargingStation: ChargingStation,
+    requestId: number,
+    signature?: string
+  ): Promise<void> {
+    await this.sendFirmwareStatusNotification(
+      chargingStation,
+      FirmwareStatusEnumType.Downloading,
+      requestId
+    )
+
+    await sleep(1000)
+    await this.sendFirmwareStatusNotification(
+      chargingStation,
+      FirmwareStatusEnumType.Downloaded,
+      requestId
+    )
+
+    if (signature != null) {
+      await sleep(500)
+      await this.sendFirmwareStatusNotification(
+        chargingStation,
+        FirmwareStatusEnumType.SignatureVerified,
+        requestId
+      )
+    }
+
+    await sleep(1000)
+    await this.sendFirmwareStatusNotification(
+      chargingStation,
+      FirmwareStatusEnumType.Installing,
+      requestId
+    )
+
+    await sleep(1000)
+    await this.sendFirmwareStatusNotification(
+      chargingStation,
+      FirmwareStatusEnumType.Installed,
+      requestId
+    )
+
+    logger.info(
+      `${chargingStation.logPrefix()} ${moduleName}.simulateFirmwareUpdateLifecycle: Firmware update simulation completed for requestId ${requestId.toString()}`
+    )
+  }
+
+  /**
+   * Simulates a log upload lifecycle through status progression using chained setTimeout calls.
+   * Sequence: Uploading → Uploaded
+   * @param chargingStation - The charging station instance
+   * @param requestId - The request ID from the GetLog request
+   */
+  private async simulateLogUploadLifecycle (
+    chargingStation: ChargingStation,
+    requestId: number
+  ): Promise<void> {
+    await this.sendLogStatusNotification(
+      chargingStation,
+      UploadLogStatusEnumType.Uploading,
+      requestId
+    )
+
+    await sleep(1000)
+    await this.sendLogStatusNotification(
+      chargingStation,
+      UploadLogStatusEnumType.Uploaded,
+      requestId
+    )
+
+    logger.info(
+      `${chargingStation.logPrefix()} ${moduleName}.simulateLogUploadLifecycle: Log upload simulation completed for requestId ${requestId.toString()}`
+    )
+  }
+
   /**
    * Terminates all active transactions on the charging station using OCPP 2.0 TransactionEventRequest
    * @param chargingStation - The charging station instance
index 50a66ccae61b3e37262eeabe9abee501417d8b51..af2be2aabe5c0c5ff74eeebca3c083e28d581c0f 100644 (file)
@@ -10,18 +10,31 @@ import {
   type CertificateActionEnumType,
   type CertificateSigningUseEnumType,
   ErrorType,
+  type FirmwareStatusEnumType,
   type JsonObject,
   type JsonType,
+  type OCPP20FirmwareStatusNotificationRequest,
+  type OCPP20FirmwareStatusNotificationResponse,
   type OCPP20Get15118EVCertificateRequest,
   type OCPP20Get15118EVCertificateResponse,
   type OCPP20GetCertificateStatusRequest,
   type OCPP20GetCertificateStatusResponse,
+  type OCPP20LogStatusNotificationRequest,
+  type OCPP20LogStatusNotificationResponse,
+  type OCPP20MeterValue,
+  type OCPP20MeterValuesRequest,
+  type OCPP20MeterValuesResponse,
+  type OCPP20NotifyCustomerInformationRequest,
+  type OCPP20NotifyCustomerInformationResponse,
   OCPP20RequestCommand,
+  type OCPP20SecurityEventNotificationRequest,
+  type OCPP20SecurityEventNotificationResponse,
   type OCPP20SignCertificateRequest,
   type OCPP20SignCertificateResponse,
   OCPPVersion,
   type OCSPRequestDataType,
   type RequestParams,
+  type UploadLogStatusEnumType,
 } from '../../../types/index.js'
 import { generateUUID, logger } from '../../../utils/index.js'
 import { OCPPRequestService } from '../OCPPRequestService.js'
@@ -73,6 +86,51 @@ export class OCPP20RequestService extends OCPPRequestService {
     this.buildRequestPayload = this.buildRequestPayload.bind(this)
   }
 
+  /**
+   * Send a FirmwareStatusNotification to the CSMS.
+   *
+   * Notifies the CSMS about the progress of a firmware update on the charging station.
+   * Per OCPP 2.0.1 use case J01, the CS sends firmware status updates during the
+   * download, verification, and installation phases of a firmware update.
+   * The response is an empty object — the CSMS acknowledges receipt without data.
+   * @param chargingStation - The charging station reporting the firmware status
+   * @param status - Current firmware update status (e.g., Downloading, Installed)
+   * @param requestId - The request ID from the original UpdateFirmware request
+   * @returns Promise resolving to the empty CSMS acknowledgement response
+   */
+  public async requestFirmwareStatusNotification (
+    chargingStation: ChargingStation,
+    status: FirmwareStatusEnumType,
+    requestId?: number
+  ): Promise<OCPP20FirmwareStatusNotificationResponse> {
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.requestFirmwareStatusNotification: Sending FirmwareStatusNotification with status '${status}'`
+    )
+
+    const requestPayload: OCPP20FirmwareStatusNotificationRequest = {
+      status,
+      ...(requestId !== undefined && { requestId }),
+    }
+
+    const messageId = generateUUID()
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.requestFirmwareStatusNotification: Sending FirmwareStatusNotification request with message ID '${messageId}'`
+    )
+
+    const response = (await this.sendMessage(
+      chargingStation,
+      messageId,
+      requestPayload,
+      OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION
+    )) as OCPP20FirmwareStatusNotificationResponse
+
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.requestFirmwareStatusNotification: Received response`
+    )
+
+    return response
+  }
+
   /**
    * Request an ISO 15118 EV certificate from the CSMS.
    *
@@ -242,6 +300,198 @@ export class OCPP20RequestService extends OCPPRequestService {
     throw new OCPPError(ErrorType.NOT_SUPPORTED, errorMsg, commandName, commandParams)
   }
 
+  /**
+   * Send a LogStatusNotification to the CSMS.
+   *
+   * Notifies the CSMS about the progress of a log upload on the charging station.
+   * Per OCPP 2.0.1 use case M04, the CS sends log upload status updates during
+   * the upload process. The response is an empty object — the CSMS acknowledges
+   * receipt without data.
+   * @param chargingStation - The charging station reporting the log upload status
+   * @param status - Current log upload status (e.g., Uploading, Uploaded)
+   * @param requestId - The request ID from the original GetLog request
+   * @returns Promise resolving to the empty CSMS acknowledgement response
+   */
+  public async requestLogStatusNotification (
+    chargingStation: ChargingStation,
+    status: UploadLogStatusEnumType,
+    requestId?: number
+  ): Promise<OCPP20LogStatusNotificationResponse> {
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.requestLogStatusNotification: Sending LogStatusNotification with status '${status}'`
+    )
+
+    const requestPayload: OCPP20LogStatusNotificationRequest = {
+      status,
+      ...(requestId !== undefined && { requestId }),
+    }
+
+    const messageId = generateUUID()
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.requestLogStatusNotification: Sending LogStatusNotification request with message ID '${messageId}'`
+    )
+
+    const response = (await this.sendMessage(
+      chargingStation,
+      messageId,
+      requestPayload,
+      OCPP20RequestCommand.LOG_STATUS_NOTIFICATION
+    )) as OCPP20LogStatusNotificationResponse
+
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.requestLogStatusNotification: Received response`
+    )
+
+    return response
+  }
+
+  /**
+   * Send MeterValues to the CSMS.
+   *
+   * Reports meter values for a specific EVSE to the CSMS outside of a transaction context.
+   * Per OCPP 2.0.1, the charging station may send sampled meter values (e.g., energy, power,
+   * voltage, current) at configured intervals or upon trigger. Each meter value contains
+   * one or more sampled values all taken at the same point in time.
+   * The response is an empty object — the CSMS acknowledges receipt without data.
+   * @param chargingStation - The charging station reporting the meter values
+   * @param evseId - The EVSE identifier (0 for main power meter, >0 for specific EVSE)
+   * @param meterValue - Array of meter value objects, each containing timestamped sampled values
+   * @returns Promise resolving to the empty CSMS acknowledgement response
+   */
+  public async requestMeterValues (
+    chargingStation: ChargingStation,
+    evseId: number,
+    meterValue: OCPP20MeterValue[]
+  ): Promise<OCPP20MeterValuesResponse> {
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.requestMeterValues: Sending MeterValues for EVSE ${evseId.toString()}`
+    )
+
+    const requestPayload: OCPP20MeterValuesRequest = {
+      evseId,
+      meterValue,
+    }
+
+    const messageId = generateUUID()
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.requestMeterValues: Sending MeterValues request with message ID '${messageId}'`
+    )
+
+    const response = (await this.sendMessage(
+      chargingStation,
+      messageId,
+      requestPayload,
+      OCPP20RequestCommand.METER_VALUES
+    )) as OCPP20MeterValuesResponse
+
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.requestMeterValues: Received response`
+    )
+
+    return response
+  }
+
+  /**
+   * Send NotifyCustomerInformation to the CSMS.
+   *
+   * Notifies the CSMS about customer information availability.
+   * For the simulator, this sends empty customer data as no real customer
+   * information is stored (GDPR compliance).
+   * @param chargingStation - The charging station sending the notification
+   * @param requestId - The request ID from the original CustomerInformation request
+   * @param data - Customer information data (empty string for simulator)
+   * @param seqNo - Sequence number for the notification
+   * @param generatedAt - Timestamp when the data was generated
+   * @param tbc - To be continued flag (false for simulator)
+   * @returns Promise resolving when the notification is sent
+   */
+  public async requestNotifyCustomerInformation (
+    chargingStation: ChargingStation,
+    requestId: number,
+    data: string,
+    seqNo: number,
+    generatedAt: Date,
+    tbc: boolean
+  ): Promise<OCPP20NotifyCustomerInformationResponse> {
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.requestNotifyCustomerInformation: Sending NotifyCustomerInformation`
+    )
+
+    const requestPayload: OCPP20NotifyCustomerInformationRequest = {
+      data,
+      generatedAt,
+      requestId,
+      seqNo,
+      tbc,
+    }
+
+    const messageId = generateUUID()
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.requestNotifyCustomerInformation: Sending NotifyCustomerInformation request with message ID '${messageId}'`
+    )
+
+    const response = (await this.sendMessage(
+      chargingStation,
+      messageId,
+      requestPayload,
+      OCPP20RequestCommand.NOTIFY_CUSTOMER_INFORMATION
+    )) as OCPP20NotifyCustomerInformationResponse
+
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.requestNotifyCustomerInformation: Received response`
+    )
+
+    return response
+  }
+
+  /**
+   * Send a SecurityEventNotification to the CSMS.
+   *
+   * Notifies the CSMS about a security event that occurred at the charging station.
+   * Per OCPP 2.0.1 use case A04, the CS sends security events (e.g., tamper detection,
+   * firmware validation failure, invalid certificate) to keep the CSMS informed.
+   * The response is an empty object — the CSMS acknowledges receipt without data.
+   * @param chargingStation - The charging station reporting the security event
+   * @param type - Type of the security event (from the Security events list, max 50 chars)
+   * @param timestamp - Date and time at which the event occurred
+   * @param techInfo - Optional additional technical information about the event (max 255 chars)
+   * @returns Promise resolving to the empty CSMS acknowledgement response
+   */
+  public async requestSecurityEventNotification (
+    chargingStation: ChargingStation,
+    type: string,
+    timestamp: Date,
+    techInfo?: string
+  ): Promise<OCPP20SecurityEventNotificationResponse> {
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.requestSecurityEventNotification: Sending SecurityEventNotification`
+    )
+
+    const requestPayload: OCPP20SecurityEventNotificationRequest = {
+      timestamp,
+      type,
+      ...(techInfo !== undefined && { techInfo }),
+    }
+
+    const messageId = generateUUID()
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.requestSecurityEventNotification: Sending SecurityEventNotification request with message ID '${messageId}'`
+    )
+
+    const response = (await this.sendMessage(
+      chargingStation,
+      messageId,
+      requestPayload,
+      OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION
+    )) as OCPP20SecurityEventNotificationResponse
+
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.requestSecurityEventNotification: Received response`
+    )
+
+    return response
+  }
+
   /**
    * Request certificate signing from the CSMS.
    *
@@ -342,6 +592,12 @@ export class OCPP20RequestService extends OCPPRequestService {
     switch (commandName) {
       case OCPP20RequestCommand.BOOT_NOTIFICATION:
         return commandParams as unknown as Request
+      case OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION:
+      case OCPP20RequestCommand.LOG_STATUS_NOTIFICATION:
+      case OCPP20RequestCommand.METER_VALUES:
+      case OCPP20RequestCommand.NOTIFY_CUSTOMER_INFORMATION:
+      case OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION:
+        return commandParams as unknown as Request
       case OCPP20RequestCommand.HEARTBEAT:
         return OCPP20Constants.OCPP_RESPONSE_EMPTY as unknown as Request
       case OCPP20RequestCommand.NOTIFY_REPORT:
index 593f8905e4aa5bf096146c144e1d86058d2b28a2..e64ca67d406053dae356f396a8352e6d7ecd9352 100644 (file)
@@ -7,17 +7,24 @@ import {
   ErrorType,
   type JsonType,
   type OCPP20BootNotificationResponse,
+  type OCPP20FirmwareStatusNotificationResponse,
   type OCPP20HeartbeatResponse,
   OCPP20IncomingRequestCommand,
+  type OCPP20LogStatusNotificationResponse,
+  type OCPP20MeterValuesResponse,
+  type OCPP20NotifyCustomerInformationResponse,
   type OCPP20NotifyReportResponse,
   OCPP20OptionalVariableName,
   OCPP20RequestCommand,
+  type OCPP20SecurityEventNotificationResponse,
   type OCPP20StatusNotificationResponse,
+  type OCPP20TransactionEventRequest,
   type OCPP20TransactionEventResponse,
   OCPPVersion,
   RegistrationStatusEnumType,
   type ResponseHandler,
 } from '../../../types/index.js'
+import { OCPP20AuthorizationStatusEnumType } from '../../../types/ocpp/2.0/Transaction.js'
 import { isAsyncFunction, logger } from '../../../utils/index.js'
 import { OCPPResponseService } from '../OCPPResponseService.js'
 import { OCPP20ServiceUtils } from './OCPP20ServiceUtils.js'
@@ -82,11 +89,31 @@ export class OCPP20ResponseService extends OCPPResponseService {
         OCPP20RequestCommand.BOOT_NOTIFICATION,
         this.handleResponseBootNotification.bind(this) as ResponseHandler,
       ],
+      [
+        OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION,
+        this.handleResponseFirmwareStatusNotification.bind(this) as ResponseHandler,
+      ],
       [OCPP20RequestCommand.HEARTBEAT, this.handleResponseHeartbeat.bind(this) as ResponseHandler],
+      [
+        OCPP20RequestCommand.LOG_STATUS_NOTIFICATION,
+        this.handleResponseLogStatusNotification.bind(this) as ResponseHandler,
+      ],
+      [
+        OCPP20RequestCommand.METER_VALUES,
+        this.handleResponseMeterValues.bind(this) as ResponseHandler,
+      ],
+      [
+        OCPP20RequestCommand.NOTIFY_CUSTOMER_INFORMATION,
+        this.handleResponseNotifyCustomerInformation.bind(this) as ResponseHandler,
+      ],
       [
         OCPP20RequestCommand.NOTIFY_REPORT,
         this.handleResponseNotifyReport.bind(this) as ResponseHandler,
       ],
+      [
+        OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION,
+        this.handleResponseSecurityEventNotification.bind(this) as ResponseHandler,
+      ],
       [
         OCPP20RequestCommand.STATUS_NOTIFICATION,
         this.handleResponseStatusNotification.bind(this) as ResponseHandler,
@@ -233,6 +260,15 @@ export class OCPP20ResponseService extends OCPPResponseService {
     }
   }
 
+  private handleResponseFirmwareStatusNotification (
+    chargingStation: ChargingStation,
+    payload: OCPP20FirmwareStatusNotificationResponse
+  ): void {
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.handleResponseFirmwareStatusNotification: FirmwareStatusNotification response received successfully`
+    )
+  }
+
   private handleResponseHeartbeat (
     chargingStation: ChargingStation,
     payload: OCPP20HeartbeatResponse
@@ -242,6 +278,33 @@ export class OCPP20ResponseService extends OCPPResponseService {
     )
   }
 
+  private handleResponseLogStatusNotification (
+    chargingStation: ChargingStation,
+    payload: OCPP20LogStatusNotificationResponse
+  ): void {
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.handleResponseLogStatusNotification: LogStatusNotification response received successfully`
+    )
+  }
+
+  private handleResponseMeterValues (
+    chargingStation: ChargingStation,
+    payload: OCPP20MeterValuesResponse
+  ): void {
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.handleResponseMeterValues: MeterValues response received successfully`
+    )
+  }
+
+  private handleResponseNotifyCustomerInformation (
+    chargingStation: ChargingStation,
+    payload: OCPP20NotifyCustomerInformationResponse
+  ): void {
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.handleResponseNotifyCustomerInformation: NotifyCustomerInformation response received successfully`
+    )
+  }
+
   private handleResponseNotifyReport (
     chargingStation: ChargingStation,
     payload: OCPP20NotifyReportResponse
@@ -251,6 +314,15 @@ export class OCPP20ResponseService extends OCPPResponseService {
     )
   }
 
+  private handleResponseSecurityEventNotification (
+    chargingStation: ChargingStation,
+    payload: OCPP20SecurityEventNotificationResponse
+  ): void {
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.handleResponseSecurityEventNotification: SecurityEventNotification response received successfully`
+    )
+  }
+
   private handleResponseStatusNotification (
     chargingStation: ChargingStation,
     payload: OCPP20StatusNotificationResponse
@@ -260,11 +332,20 @@ export class OCPP20ResponseService extends OCPPResponseService {
     )
   }
 
-  // TODO: currently log-only — future work should act on idTokenInfo.status (Invalid/Blocked → stop transaction)
-  // and chargingPriority (update charging profile priority) per OCPP 2.0.1 spec
+  /**
+   * Handles TransactionEvent response from CSMS.
+   *
+   * Per OCPP 2.0.1 spec (D01, D05): If the Charging Station started a transaction based on
+   * local authorization, but receives an Invalid, Blocked, Expired, or NoCredit status in the
+   * TransactionEventResponse idTokenInfo, the Charging Station SHALL stop the transaction.
+   * @param chargingStation - The charging station instance
+   * @param payload - The TransactionEvent response payload from CSMS
+   * @param requestPayload - The original TransactionEvent request payload
+   */
   private handleResponseTransactionEvent (
     chargingStation: ChargingStation,
-    payload: OCPP20TransactionEventResponse
+    payload: OCPP20TransactionEventResponse,
+    requestPayload: OCPP20TransactionEventRequest
   ): void {
     logger.debug(
       `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: TransactionEvent response received`
@@ -283,6 +364,42 @@ export class OCPP20ResponseService extends OCPPResponseService {
       logger.info(
         `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: IdToken info status: ${payload.idTokenInfo.status}`
       )
+      // D01/D05: Stop transaction when idToken authorization is rejected by CSMS
+      const rejectedStatuses = new Set<OCPP20AuthorizationStatusEnumType>([
+        OCPP20AuthorizationStatusEnumType.Blocked,
+        OCPP20AuthorizationStatusEnumType.Expired,
+        OCPP20AuthorizationStatusEnumType.Invalid,
+        OCPP20AuthorizationStatusEnumType.NoCredit,
+      ])
+      if (rejectedStatuses.has(payload.idTokenInfo.status)) {
+        logger.warn(
+          `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: IdToken authorization rejected with status '${payload.idTokenInfo.status}', stopping active transaction per OCPP 2.0.1 spec (D01/D05)`
+        )
+        // Find the specific connector for this transaction
+        const connectorId = chargingStation.getConnectorIdByTransactionId(
+          requestPayload.transactionInfo.transactionId
+        )
+        const evseId = chargingStation.getEvseIdByTransactionId(
+          requestPayload.transactionInfo.transactionId
+        )
+        if (connectorId != null && evseId != null) {
+          logger.info(
+            `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Stopping transaction ${requestPayload.transactionInfo.transactionId} on EVSE ${evseId.toString()}, connector ${connectorId.toString()} due to rejected idToken`
+          )
+          OCPP20ServiceUtils.requestStopTransaction(chargingStation, connectorId, evseId).catch(
+            (error: unknown) => {
+              logger.error(
+                `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Error stopping transaction ${requestPayload.transactionInfo.transactionId} on connector ${connectorId.toString()}:`,
+                error
+              )
+            }
+          )
+        } else {
+          logger.warn(
+            `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Could not find connector for transaction ${requestPayload.transactionInfo.transactionId}, cannot stop transaction`
+          )
+        }
+      }
     }
     if (payload.updatedPersonalMessage != null) {
       logger.info(
index b9da34f23597691612699f006d019580b9a4ccf7..fba639bd959a0ff2e5b33302a09c19bbbbd2e4e4 100644 (file)
@@ -39,6 +39,45 @@ import { OCPP20Constants } from './OCPP20Constants.js'
 const moduleName = 'OCPP20ServiceUtils'
 
 export class OCPP20ServiceUtils extends OCPPServiceUtils {
+  private static readonly incomingRequestSchemaNames: readonly [
+    OCPP20IncomingRequestCommand,
+    string
+  ][] = [
+      [OCPP20IncomingRequestCommand.CERTIFICATE_SIGNED, 'CertificateSigned'],
+      [OCPP20IncomingRequestCommand.CHANGE_AVAILABILITY, 'ChangeAvailability'],
+      [OCPP20IncomingRequestCommand.CLEAR_CACHE, 'ClearCache'],
+      [OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION, 'CustomerInformation'],
+      [OCPP20IncomingRequestCommand.DATA_TRANSFER, 'DataTransfer'],
+      [OCPP20IncomingRequestCommand.DELETE_CERTIFICATE, 'DeleteCertificate'],
+      [OCPP20IncomingRequestCommand.GET_BASE_REPORT, 'GetBaseReport'],
+      [OCPP20IncomingRequestCommand.GET_INSTALLED_CERTIFICATE_IDS, 'GetInstalledCertificateIds'],
+      [OCPP20IncomingRequestCommand.GET_LOG, 'GetLog'],
+      [OCPP20IncomingRequestCommand.GET_TRANSACTION_STATUS, 'GetTransactionStatus'],
+      [OCPP20IncomingRequestCommand.GET_VARIABLES, 'GetVariables'],
+      [OCPP20IncomingRequestCommand.INSTALL_CERTIFICATE, 'InstallCertificate'],
+      [OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION, 'RequestStartTransaction'],
+      [OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION, 'RequestStopTransaction'],
+      [OCPP20IncomingRequestCommand.RESET, 'Reset'],
+      [OCPP20IncomingRequestCommand.SET_NETWORK_PROFILE, 'SetNetworkProfile'],
+      [OCPP20IncomingRequestCommand.SET_VARIABLES, 'SetVariables'],
+      [OCPP20IncomingRequestCommand.TRIGGER_MESSAGE, 'TriggerMessage'],
+      [OCPP20IncomingRequestCommand.UNLOCK_CONNECTOR, 'UnlockConnector'],
+      [OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, 'UpdateFirmware'],
+    ]
+
+  private static readonly outgoingRequestSchemaNames: readonly [OCPP20RequestCommand, string][] = [
+    [OCPP20RequestCommand.BOOT_NOTIFICATION, 'BootNotification'],
+    [OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION, 'FirmwareStatusNotification'],
+    [OCPP20RequestCommand.HEARTBEAT, 'Heartbeat'],
+    [OCPP20RequestCommand.LOG_STATUS_NOTIFICATION, 'LogStatusNotification'],
+    [OCPP20RequestCommand.METER_VALUES, 'MeterValues'],
+    [OCPP20RequestCommand.NOTIFY_CUSTOMER_INFORMATION, 'NotifyCustomerInformation'],
+    [OCPP20RequestCommand.NOTIFY_REPORT, 'NotifyReport'],
+    [OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION, 'SecurityEventNotification'],
+    [OCPP20RequestCommand.STATUS_NOTIFICATION, 'StatusNotification'],
+    [OCPP20RequestCommand.TRANSACTION_EVENT, 'TransactionEvent'],
+  ]
+
   /**
    * Build a TransactionEvent request according to OCPP 2.0.1 specification
    *
@@ -209,60 +248,11 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
   public static createIncomingRequestPayloadConfigs = (): [
     OCPP20IncomingRequestCommand,
     { schemaPath: string }
-  ][] => [
-    [
-      OCPP20IncomingRequestCommand.CERTIFICATE_SIGNED,
-      OCPP20ServiceUtils.PayloadValidatorConfig('CertificateSignedRequest.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.CLEAR_CACHE,
-      OCPP20ServiceUtils.PayloadValidatorConfig('ClearCacheRequest.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.DELETE_CERTIFICATE,
-      OCPP20ServiceUtils.PayloadValidatorConfig('DeleteCertificateRequest.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.GET_BASE_REPORT,
-      OCPP20ServiceUtils.PayloadValidatorConfig('GetBaseReportRequest.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.GET_INSTALLED_CERTIFICATE_IDS,
-      OCPP20ServiceUtils.PayloadValidatorConfig('GetInstalledCertificateIdsRequest.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.GET_VARIABLES,
-      OCPP20ServiceUtils.PayloadValidatorConfig('GetVariablesRequest.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.INSTALL_CERTIFICATE,
-      OCPP20ServiceUtils.PayloadValidatorConfig('InstallCertificateRequest.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
-      OCPP20ServiceUtils.PayloadValidatorConfig('RequestStartTransactionRequest.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
-      OCPP20ServiceUtils.PayloadValidatorConfig('RequestStopTransactionRequest.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.RESET,
-      OCPP20ServiceUtils.PayloadValidatorConfig('ResetRequest.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.SET_VARIABLES,
-      OCPP20ServiceUtils.PayloadValidatorConfig('SetVariablesRequest.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
-      OCPP20ServiceUtils.PayloadValidatorConfig('TriggerMessageRequest.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.UNLOCK_CONNECTOR,
-      OCPP20ServiceUtils.PayloadValidatorConfig('UnlockConnectorRequest.json'),
-    ],
-  ]
+  ][] =>
+    OCPP20ServiceUtils.incomingRequestSchemaNames.map(([command, schemaBase]) => [
+      command,
+      OCPP20ServiceUtils.PayloadValidatorConfig(`${schemaBase}Request.json`),
+    ])
 
   /**
    * Factory options for OCPP 2.0 Incoming Request Service
@@ -285,60 +275,11 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
   public static createIncomingRequestResponsePayloadConfigs = (): [
     OCPP20IncomingRequestCommand,
     { schemaPath: string }
-  ][] => [
-    [
-      OCPP20IncomingRequestCommand.CERTIFICATE_SIGNED,
-      OCPP20ServiceUtils.PayloadValidatorConfig('CertificateSignedResponse.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.CLEAR_CACHE,
-      OCPP20ServiceUtils.PayloadValidatorConfig('ClearCacheResponse.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.DELETE_CERTIFICATE,
-      OCPP20ServiceUtils.PayloadValidatorConfig('DeleteCertificateResponse.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.GET_BASE_REPORT,
-      OCPP20ServiceUtils.PayloadValidatorConfig('GetBaseReportResponse.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.GET_INSTALLED_CERTIFICATE_IDS,
-      OCPP20ServiceUtils.PayloadValidatorConfig('GetInstalledCertificateIdsResponse.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.GET_VARIABLES,
-      OCPP20ServiceUtils.PayloadValidatorConfig('GetVariablesResponse.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.INSTALL_CERTIFICATE,
-      OCPP20ServiceUtils.PayloadValidatorConfig('InstallCertificateResponse.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
-      OCPP20ServiceUtils.PayloadValidatorConfig('RequestStartTransactionResponse.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
-      OCPP20ServiceUtils.PayloadValidatorConfig('RequestStopTransactionResponse.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.RESET,
-      OCPP20ServiceUtils.PayloadValidatorConfig('ResetResponse.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.SET_VARIABLES,
-      OCPP20ServiceUtils.PayloadValidatorConfig('SetVariablesResponse.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
-      OCPP20ServiceUtils.PayloadValidatorConfig('TriggerMessageResponse.json'),
-    ],
-    [
-      OCPP20IncomingRequestCommand.UNLOCK_CONNECTOR,
-      OCPP20ServiceUtils.PayloadValidatorConfig('UnlockConnectorResponse.json'),
-    ],
-  ]
+  ][] =>
+    OCPP20ServiceUtils.incomingRequestSchemaNames.map(([command, schemaBase]) => [
+      command,
+      OCPP20ServiceUtils.PayloadValidatorConfig(`${schemaBase}Response.json`),
+    ])
 
   /**
    * Factory options for OCPP 2.0 Incoming Request Response Service
@@ -364,28 +305,11 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
   public static createRequestPayloadConfigs = (): [
     OCPP20RequestCommand,
     { schemaPath: string }
-  ][] => [
-    [
-      OCPP20RequestCommand.BOOT_NOTIFICATION,
-      OCPP20ServiceUtils.PayloadValidatorConfig('BootNotificationRequest.json'),
-    ],
-    [
-      OCPP20RequestCommand.HEARTBEAT,
-      OCPP20ServiceUtils.PayloadValidatorConfig('HeartbeatRequest.json'),
-    ],
-    [
-      OCPP20RequestCommand.NOTIFY_REPORT,
-      OCPP20ServiceUtils.PayloadValidatorConfig('NotifyReportRequest.json'),
-    ],
-    [
-      OCPP20RequestCommand.STATUS_NOTIFICATION,
-      OCPP20ServiceUtils.PayloadValidatorConfig('StatusNotificationRequest.json'),
-    ],
-    [
-      OCPP20RequestCommand.TRANSACTION_EVENT,
-      OCPP20ServiceUtils.PayloadValidatorConfig('TransactionEventRequest.json'),
-    ],
-  ]
+  ][] =>
+    OCPP20ServiceUtils.outgoingRequestSchemaNames.map(([command, schemaBase]) => [
+      command,
+      OCPP20ServiceUtils.PayloadValidatorConfig(`${schemaBase}Request.json`),
+    ])
 
   /**
    * Factory options for OCPP 2.0 Request Service
@@ -408,28 +332,11 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
   public static createResponsePayloadConfigs = (): [
     OCPP20RequestCommand,
     { schemaPath: string }
-  ][] => [
-    [
-      OCPP20RequestCommand.BOOT_NOTIFICATION,
-      OCPP20ServiceUtils.PayloadValidatorConfig('BootNotificationResponse.json'),
-    ],
-    [
-      OCPP20RequestCommand.HEARTBEAT,
-      OCPP20ServiceUtils.PayloadValidatorConfig('HeartbeatResponse.json'),
-    ],
-    [
-      OCPP20RequestCommand.NOTIFY_REPORT,
-      OCPP20ServiceUtils.PayloadValidatorConfig('NotifyReportResponse.json'),
-    ],
-    [
-      OCPP20RequestCommand.STATUS_NOTIFICATION,
-      OCPP20ServiceUtils.PayloadValidatorConfig('StatusNotificationResponse.json'),
-    ],
-    [
-      OCPP20RequestCommand.TRANSACTION_EVENT,
-      OCPP20ServiceUtils.PayloadValidatorConfig('TransactionEventResponse.json'),
-    ],
-  ]
+  ][] =>
+    OCPP20ServiceUtils.outgoingRequestSchemaNames.map(([command, schemaBase]) => [
+      command,
+      OCPP20ServiceUtils.PayloadValidatorConfig(`${schemaBase}Response.json`),
+    ])
 
   /**
    * Factory options for OCPP 2.0 Response Service
index 81dd5f0c0c21f0872c23ff4e7f7e368419974d61..c1bcd313d4816b6d6e60085e6bb7f0ef704e3957 100644 (file)
@@ -24,13 +24,20 @@ import { mock } from 'node:test'
 import type {
   CertificateActionEnumType,
   CertificateSigningUseEnumType,
+  FirmwareStatusEnumType,
   JsonType,
+  OCPP20FirmwareStatusNotificationResponse,
   OCPP20Get15118EVCertificateResponse,
   OCPP20GetCertificateStatusResponse,
+  OCPP20LogStatusNotificationResponse,
+  OCPP20MeterValue,
+  OCPP20MeterValuesResponse,
   OCPP20RequestCommand,
+  OCPP20SecurityEventNotificationResponse,
   OCPP20SignCertificateResponse,
   OCSPRequestDataType,
   RequestParams,
+  UploadLogStatusEnumType,
 } from '../../../../types/index.js'
 import type { ChargingStation } from '../../../index.js'
 
@@ -75,6 +82,16 @@ export interface TestableOCPP20RequestService {
     commandParams?: JsonType
   ) => JsonType
 
+  /**
+   * Send a FirmwareStatusNotification to the CSMS.
+   * Reports firmware update progress to the CSMS.
+   */
+  requestFirmwareStatusNotification: (
+    chargingStation: ChargingStation,
+    status: FirmwareStatusEnumType,
+    requestId?: number
+  ) => Promise<OCPP20FirmwareStatusNotificationResponse>
+
   /**
    * Request an ISO 15118 EV certificate from the CSMS.
    * Forwards EXI-encoded certificate request from EV to CSMS.
@@ -94,6 +111,36 @@ export interface TestableOCPP20RequestService {
     chargingStation: ChargingStation,
     ocspRequestData: OCSPRequestDataType
   ) => Promise<OCPP20GetCertificateStatusResponse>
+
+  /**
+   * Send a LogStatusNotification to the CSMS.
+   * Reports the status of a log upload initiated by a GetLog request.
+   */
+  requestLogStatusNotification: (
+    chargingStation: ChargingStation,
+    status: UploadLogStatusEnumType,
+    requestId?: number
+  ) => Promise<OCPP20LogStatusNotificationResponse>
+
+  /**
+   * Send MeterValues to the CSMS.
+   * Reports meter values for a specific EVSE outside of a transaction context.
+   */
+  requestMeterValues: (
+    chargingStation: ChargingStation,
+    evseId: number,
+    meterValue: OCPP20MeterValue[]
+  ) => Promise<OCPP20MeterValuesResponse>
+  /**
+   * Send a SecurityEventNotification to the CSMS.
+   * Notifies the CSMS about a security event at the charging station (A04).
+   */
+  requestSecurityEventNotification: (
+    chargingStation: ChargingStation,
+    type: string,
+    timestamp: Date,
+    techInfo?: string
+  ) => Promise<OCPP20SecurityEventNotificationResponse>
   /**
    * Request certificate signing from the CSMS.
    * Generates a CSR and sends it to CSMS for signing.
index 6fd50748e88c524307cd1ff836c8eafc41081e25..4ea125c8ba49401226171e21e13cbb51bba9416e 100644 (file)
 import type {
   OCPP20CertificateSignedRequest,
   OCPP20CertificateSignedResponse,
+  OCPP20ChangeAvailabilityRequest,
+  OCPP20ChangeAvailabilityResponse,
   OCPP20ClearCacheResponse,
+  OCPP20CustomerInformationRequest,
+  OCPP20CustomerInformationResponse,
+  OCPP20DataTransferRequest,
+  OCPP20DataTransferResponse,
   OCPP20DeleteCertificateRequest,
   OCPP20DeleteCertificateResponse,
   OCPP20GetBaseReportRequest,
   OCPP20GetBaseReportResponse,
   OCPP20GetInstalledCertificateIdsRequest,
   OCPP20GetInstalledCertificateIdsResponse,
+  OCPP20GetLogRequest,
+  OCPP20GetLogResponse,
+  OCPP20GetTransactionStatusRequest,
+  OCPP20GetTransactionStatusResponse,
   OCPP20GetVariablesRequest,
   OCPP20GetVariablesResponse,
   OCPP20InstallCertificateRequest,
@@ -35,12 +45,16 @@ import type {
   OCPP20RequestStopTransactionResponse,
   OCPP20ResetRequest,
   OCPP20ResetResponse,
+  OCPP20SetNetworkProfileRequest,
+  OCPP20SetNetworkProfileResponse,
   OCPP20SetVariablesRequest,
   OCPP20SetVariablesResponse,
   OCPP20TriggerMessageRequest,
   OCPP20TriggerMessageResponse,
   OCPP20UnlockConnectorRequest,
   OCPP20UnlockConnectorResponse,
+  OCPP20UpdateFirmwareRequest,
+  OCPP20UpdateFirmwareResponse,
   ReportBaseEnumType,
   ReportDataType,
 } from '../../../../types/index.js'
@@ -62,7 +76,7 @@ export interface TestableOCPP20IncomingRequestService {
   ) => ReportDataType[]
 
   /**
-   * Handles OCPP 2.0 CertificateSigned request from central system.
+   * Handles OCPP 2.0.1 CertificateSigned request from central system.
    * Receives signed certificate chain from CSMS and stores it in the charging station.
    */
   handleRequestCertificateSigned: (
@@ -70,12 +84,39 @@ export interface TestableOCPP20IncomingRequestService {
     commandPayload: OCPP20CertificateSignedRequest
   ) => Promise<OCPP20CertificateSignedResponse>
 
+  /**
+   * Handles OCPP 2.0.1 ChangeAvailability request from central system.
+   * Changes operational status of the entire charging station or a specific EVSE.
+   */
+  handleRequestChangeAvailability: (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20ChangeAvailabilityRequest
+  ) => OCPP20ChangeAvailabilityResponse
+
   /**
    * Handles OCPP 2.0.1 ClearCache request by clearing the Authorization Cache.
    * Per C11.FR.04: Returns Rejected if AuthCacheEnabled is false.
    */
   handleRequestClearCache: (chargingStation: ChargingStation) => Promise<OCPP20ClearCacheResponse>
 
+  /**
+   * Handles OCPP 2.0.1 CustomerInformation request from central system.
+   * Per TC_N_32_CS: CS must respond to CustomerInformation with Accepted for clear requests.
+   */
+  handleRequestCustomerInformation: (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20CustomerInformationRequest
+  ) => OCPP20CustomerInformationResponse
+
+  /**
+   * Handles OCPP 2.0.1 DataTransfer request.
+   * Per TC_P_01_CS: CS with no vendor extensions must respond UnknownVendorId.
+   */
+  handleRequestDataTransfer: (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20DataTransferRequest
+  ) => OCPP20DataTransferResponse
+
   /**
    * Handles OCPP 2.0 DeleteCertificate request from central system.
    * Deletes a certificate matching the provided hash data from the charging station.
@@ -103,6 +144,24 @@ export interface TestableOCPP20IncomingRequestService {
     commandPayload: OCPP20GetInstalledCertificateIdsRequest
   ) => Promise<OCPP20GetInstalledCertificateIdsResponse>
 
+  /**
+   * Handles OCPP 2.0.1 GetLog request from central system.
+   * Accepts log upload and simulates upload lifecycle.
+   */
+  handleRequestGetLog: (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20GetLogRequest
+  ) => OCPP20GetLogResponse
+
+  /**
+   * Handles OCPP 2.0.1 GetTransactionStatus request from central system.
+   * Returns transaction status with ongoingIndicator and messagesInQueue.
+   */
+  handleRequestGetTransactionStatus: (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20GetTransactionStatusRequest
+  ) => OCPP20GetTransactionStatusResponse
+
   /**
    * Handles OCPP 2.0 GetVariables request.
    * Returns values for requested variables from the device model.
@@ -130,6 +189,15 @@ export interface TestableOCPP20IncomingRequestService {
     commandPayload: OCPP20ResetRequest
   ) => Promise<OCPP20ResetResponse>
 
+  /**
+   * Handles OCPP 2.0.1 SetNetworkProfile request from central system.
+   * Per TC_B_43_CS: CS must respond to SetNetworkProfile at minimum with Rejected.
+   */
+  handleRequestSetNetworkProfile: (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20SetNetworkProfileRequest
+  ) => OCPP20SetNetworkProfileResponse
+
   /**
    * Handles OCPP 2.0 SetVariables request.
    * Sets values for requested variables in the device model.
@@ -166,6 +234,11 @@ export interface TestableOCPP20IncomingRequestService {
     chargingStation: ChargingStation,
     commandPayload: OCPP20UnlockConnectorRequest
   ) => Promise<OCPP20UnlockConnectorResponse>
+
+  handleRequestUpdateFirmware: (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20UpdateFirmwareRequest
+  ) => OCPP20UpdateFirmwareResponse
 }
 
 /**
@@ -193,19 +266,26 @@ export function createTestableIncomingRequestService (
     // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
     buildReportData: (serviceImpl as any).buildReportData.bind(service),
     handleRequestCertificateSigned: serviceImpl.handleRequestCertificateSigned.bind(service),
+    handleRequestChangeAvailability: serviceImpl.handleRequestChangeAvailability.bind(service),
     handleRequestClearCache: serviceImpl.handleRequestClearCache.bind(service),
+    handleRequestCustomerInformation: serviceImpl.handleRequestCustomerInformation.bind(service),
+    handleRequestDataTransfer: serviceImpl.handleRequestDataTransfer.bind(service),
     handleRequestDeleteCertificate: serviceImpl.handleRequestDeleteCertificate.bind(service),
     handleRequestGetBaseReport: serviceImpl.handleRequestGetBaseReport.bind(service),
     handleRequestGetInstalledCertificateIds:
       serviceImpl.handleRequestGetInstalledCertificateIds.bind(service),
+    handleRequestGetLog: serviceImpl.handleRequestGetLog.bind(service),
+    handleRequestGetTransactionStatus: serviceImpl.handleRequestGetTransactionStatus.bind(service),
     handleRequestGetVariables: serviceImpl.handleRequestGetVariables.bind(service),
     handleRequestInstallCertificate: serviceImpl.handleRequestInstallCertificate.bind(service),
     handleRequestReset: serviceImpl.handleRequestReset.bind(service),
+    handleRequestSetNetworkProfile: serviceImpl.handleRequestSetNetworkProfile.bind(service),
     handleRequestSetVariables: serviceImpl.handleRequestSetVariables.bind(service),
     handleRequestStartTransaction: serviceImpl.handleRequestStartTransaction.bind(service),
     handleRequestStopTransaction: serviceImpl.handleRequestStopTransaction.bind(service),
     handleRequestTriggerMessage: serviceImpl.handleRequestTriggerMessage.bind(service),
     handleRequestUnlockConnector: serviceImpl.handleRequestUnlockConnector.bind(service),
+    handleRequestUpdateFirmware: serviceImpl.handleRequestUpdateFirmware.bind(service),
   }
 }
 
index de11ef99092fe943047c76f550e3f5e11f590a13..8e098d67527bf793b961e44f9672cc453cf1a660 100644 (file)
@@ -147,9 +147,14 @@ export {
   type CertificateHashDataChainType,
   type CertificateHashDataType,
   CertificateSigningUseEnumType,
+  ChangeAvailabilityStatusEnumType,
   type CustomDataType,
+  CustomerInformationStatusEnumType,
   DataEnumType,
+  DataTransferStatusEnumType,
   DeleteCertificateStatusEnumType,
+  FirmwareStatusEnumType,
+  type FirmwareType,
   GenericDeviceModelStatusEnumType,
   GetCertificateIdUseEnumType,
   GetCertificateStatusEnumType,
@@ -158,16 +163,24 @@ export {
   InstallCertificateStatusEnumType,
   InstallCertificateUseEnumType,
   Iso15118EVCertificateStatusEnumType,
+  LogEnumType,
+  type LogParametersType,
+  LogStatusEnumType,
   MessageTriggerEnumType,
+  type NetworkConnectionProfileType,
   OCPP20ComponentName,
   OCPP20UnitEnumType,
   type OCSPRequestDataType,
+  OperationalStatusEnumType,
   ReasonCodeEnumType,
   ReportBaseEnumType,
   ResetEnumType,
   ResetStatusEnumType,
+  SetNetworkProfileStatusEnumType,
   TriggerMessageStatusEnumType,
   UnlockStatusEnumType,
+  UpdateFirmwareStatusEnumType,
+  UploadLogStatusEnumType,
 } from './ocpp/2.0/Common.js'
 export {
   OCPP20LocationEnumType,
@@ -184,48 +197,70 @@ export {
 export {
   type OCPP20BootNotificationRequest,
   type OCPP20CertificateSignedRequest,
+  type OCPP20ChangeAvailabilityRequest,
   type OCPP20ClearCacheRequest,
+  type OCPP20CustomerInformationRequest,
+  type OCPP20DataTransferRequest,
   type OCPP20DeleteCertificateRequest,
+  type OCPP20FirmwareStatusNotificationRequest,
   type OCPP20Get15118EVCertificateRequest,
   type OCPP20GetBaseReportRequest,
   type OCPP20GetCertificateStatusRequest,
   type OCPP20GetInstalledCertificateIdsRequest,
+  type OCPP20GetLogRequest,
+  type OCPP20GetTransactionStatusRequest,
   type OCPP20GetVariablesRequest,
   type OCPP20HeartbeatRequest,
   OCPP20IncomingRequestCommand,
   type OCPP20InstallCertificateRequest,
+  type OCPP20LogStatusNotificationRequest,
+  type OCPP20NotifyCustomerInformationRequest,
   type OCPP20NotifyReportRequest,
   OCPP20RequestCommand,
   type OCPP20RequestStartTransactionRequest,
   type OCPP20RequestStopTransactionRequest,
   type OCPP20ResetRequest,
+  type OCPP20SecurityEventNotificationRequest,
+  type OCPP20SetNetworkProfileRequest,
   type OCPP20SetVariablesRequest,
   type OCPP20SignCertificateRequest,
   type OCPP20StatusNotificationRequest,
   type OCPP20TriggerMessageRequest,
   type OCPP20UnlockConnectorRequest,
+  type OCPP20UpdateFirmwareRequest,
 } from './ocpp/2.0/Requests.js'
 export type {
   OCPP20BootNotificationResponse,
   OCPP20CertificateSignedResponse,
+  OCPP20ChangeAvailabilityResponse,
   OCPP20ClearCacheResponse,
+  OCPP20CustomerInformationResponse,
+  OCPP20DataTransferResponse,
   OCPP20DeleteCertificateResponse,
+  OCPP20FirmwareStatusNotificationResponse,
   OCPP20Get15118EVCertificateResponse,
   OCPP20GetBaseReportResponse,
   OCPP20GetCertificateStatusResponse,
   OCPP20GetInstalledCertificateIdsResponse,
+  OCPP20GetLogResponse,
+  OCPP20GetTransactionStatusResponse,
   OCPP20GetVariablesResponse,
   OCPP20HeartbeatResponse,
   OCPP20InstallCertificateResponse,
+  OCPP20LogStatusNotificationResponse,
+  OCPP20NotifyCustomerInformationResponse,
   OCPP20NotifyReportResponse,
   OCPP20RequestStartTransactionResponse,
   OCPP20RequestStopTransactionResponse,
   OCPP20ResetResponse,
+  OCPP20SecurityEventNotificationResponse,
+  OCPP20SetNetworkProfileResponse,
   OCPP20SetVariablesResponse,
   OCPP20SignCertificateResponse,
   OCPP20StatusNotificationResponse,
   OCPP20TriggerMessageResponse,
   OCPP20UnlockConnectorResponse,
+  OCPP20UpdateFirmwareResponse,
 } from './ocpp/2.0/Responses.js'
 export {
   type ComponentType,
index ffec9145b8f34966a616120b751e0d1e53592b1d..61a4b66f6de5c210b25401693cc36a5cd4c5e55e 100644 (file)
@@ -1,6 +1,13 @@
 import type { JsonObject } from '../../JsonType.js'
 import type { GenericStatus } from '../Common.js'
 
+export enum APNAuthenticationEnumType {
+  AUTO = 'AUTO',
+  CHAP = 'CHAP',
+  NONE = 'NONE',
+  PAP = 'PAP',
+}
+
 export enum BootReasonEnumType {
   ApplicationReset = 'ApplicationReset',
   FirmwareUpdate = 'FirmwareUpdate',
@@ -23,6 +30,18 @@ export enum CertificateSigningUseEnumType {
   V2GCertificate = 'V2GCertificate',
 }
 
+export enum ChangeAvailabilityStatusEnumType {
+  Accepted = 'Accepted',
+  Rejected = 'Rejected',
+  Scheduled = 'Scheduled',
+}
+
+export enum CustomerInformationStatusEnumType {
+  Accepted = 'Accepted',
+  Invalid = 'Invalid',
+  Rejected = 'Rejected',
+}
+
 export enum DataEnumType {
   boolean = 'boolean',
   dateTime = 'dateTime',
@@ -34,12 +53,36 @@ export enum DataEnumType {
   string = 'string',
 }
 
+export enum DataTransferStatusEnumType {
+  Accepted = 'Accepted',
+  Rejected = 'Rejected',
+  UnknownMessageId = 'UnknownMessageId',
+  UnknownVendorId = 'UnknownVendorId',
+}
+
 export enum DeleteCertificateStatusEnumType {
   Accepted = 'Accepted',
   Failed = 'Failed',
   NotFound = 'NotFound',
 }
 
+export enum FirmwareStatusEnumType {
+  Downloaded = 'Downloaded',
+  DownloadFailed = 'DownloadFailed',
+  Downloading = 'Downloading',
+  DownloadPaused = 'DownloadPaused',
+  DownloadScheduled = 'DownloadScheduled',
+  Idle = 'Idle',
+  InstallationFailed = 'InstallationFailed',
+  Installed = 'Installed',
+  Installing = 'Installing',
+  InstallRebooting = 'InstallRebooting',
+  InstallScheduled = 'InstallScheduled',
+  InstallVerificationFailed = 'InstallVerificationFailed',
+  InvalidSignature = 'InvalidSignature',
+  SignatureVerified = 'SignatureVerified',
+}
+
 export enum GenericDeviceModelStatusEnumType {
   Accepted = 'Accepted',
   EmptyResultSet = 'EmptyResultSet',
@@ -89,6 +132,17 @@ export enum Iso15118EVCertificateStatusEnumType {
   Failed = 'Failed',
 }
 
+export enum LogEnumType {
+  DiagnosticsLog = 'DiagnosticsLog',
+  SecurityLog = 'SecurityLog',
+}
+
+export enum LogStatusEnumType {
+  Accepted = 'Accepted',
+  AcceptedCanceled = 'AcceptedCanceled',
+  Rejected = 'Rejected',
+}
+
 export enum MessageTriggerEnumType {
   BootNotification = 'BootNotification',
   FirmwareStatusNotification = 'FirmwareStatusNotification',
@@ -218,6 +272,29 @@ export enum OCPP20UnitEnumType {
   WATT_HOUR = 'Wh',
 }
 
+export enum OCPPInterfaceEnumType {
+  Wired0 = 'Wired0',
+  Wired1 = 'Wired1',
+  Wired2 = 'Wired2',
+  Wired3 = 'Wired3',
+  Wireless0 = 'Wireless0',
+  Wireless1 = 'Wireless1',
+  Wireless2 = 'Wireless2',
+  Wireless3 = 'Wireless3',
+}
+
+export enum OCPPTransportEnumType {
+  JSON = 'JSON',
+  SOAP = 'SOAP',
+}
+
+export enum OCPPVersionEnumType {
+  OCPP12 = 'OCPP12',
+  OCPP15 = 'OCPP15',
+  OCPP16 = 'OCPP16',
+  OCPP20 = 'OCPP20',
+}
+
 export enum OperationalStatusEnumType {
   Inoperative = 'Inoperative',
   Operative = 'Operative',
@@ -286,6 +363,12 @@ export enum ResetStatusEnumType {
   Scheduled = 'Scheduled',
 }
 
+export enum SetNetworkProfileStatusEnumType {
+  Accepted = 'Accepted',
+  Failed = 'Failed',
+  Rejected = 'Rejected',
+}
+
 export enum TriggerMessageStatusEnumType {
   Accepted = 'Accepted',
   NotImplemented = 'NotImplemented',
@@ -299,6 +382,43 @@ export enum UnlockStatusEnumType {
   UnlockFailed = 'UnlockFailed',
 }
 
+export enum UpdateFirmwareStatusEnumType {
+  Accepted = 'Accepted',
+  AcceptedCanceled = 'AcceptedCanceled',
+  InvalidCertificate = 'InvalidCertificate',
+  Rejected = 'Rejected',
+  RevokedCertificate = 'RevokedCertificate',
+}
+
+export enum UploadLogStatusEnumType {
+  AcceptedCanceled = 'AcceptedCanceled',
+  BadMessage = 'BadMessage',
+  Idle = 'Idle',
+  NotSupportedOperation = 'NotSupportedOperation',
+  PermissionDenied = 'PermissionDenied',
+  Uploaded = 'Uploaded',
+  UploadFailure = 'UploadFailure',
+  Uploading = 'Uploading',
+}
+
+export enum VPNEnumType {
+  IKEv2 = 'IKEv2',
+  IPSec = 'IPSec',
+  L2TP = 'L2TP',
+  PPTP = 'PPTP',
+}
+
+export interface APNType extends JsonObject {
+  apn: string
+  apnAuthentication: APNAuthenticationEnumType
+  apnPassword?: string
+  apnUserName?: string
+  customData?: CustomDataType
+  preferredNetwork?: string
+  simPin?: number
+  useOnlyPreferredNetwork?: boolean
+}
+
 export interface CertificateHashDataChainType extends JsonObject {
   certificateHashData: CertificateHashDataType
   certificateType: GetCertificateIdUseEnumType
@@ -327,14 +447,42 @@ export interface CustomDataType extends JsonObject {
   vendorId: string
 }
 
+export interface FirmwareType extends JsonObject {
+  customData?: CustomDataType
+  installDateTime?: Date
+  location: string
+  retrieveDateTime: Date
+  signature?: string
+  signingCertificate?: string
+}
+
 export type GenericStatusEnumType = GenericStatus
 
+export interface LogParametersType extends JsonObject {
+  customData?: CustomDataType
+  latestTimestamp?: Date
+  oldestTimestamp?: Date
+  remoteLocation: string
+}
+
 export interface ModemType extends JsonObject {
   customData?: CustomDataType
   iccid?: string
   imsi?: string
 }
 
+export interface NetworkConnectionProfileType extends JsonObject {
+  apn?: APNType
+  customData?: CustomDataType
+  messageTimeout: number
+  ocppCsmsUrl: string
+  ocppInterface: OCPPInterfaceEnumType
+  ocppTransport: OCPPTransportEnumType
+  ocppVersion: OCPPVersionEnumType
+  securityProfile: number
+  vpn?: VPNType
+}
+
 export interface OCSPRequestDataType extends JsonObject {
   hashAlgorithm: HashAlgorithmEnumType
   issuerKeyHash: string
@@ -348,3 +496,13 @@ export interface StatusInfoType extends JsonObject {
   customData?: CustomDataType
   reasonCode: ReasonCodeEnumType
 }
+
+export interface VPNType extends JsonObject {
+  customData?: CustomDataType
+  group?: string
+  key: string
+  password: string
+  server: string
+  type: VPNEnumType
+  user: string
+}
index b7a9b9522abf4cde3677dd1d70605186e3d88d4b..e821265190f17444eeff09c2915ff28e9eac3384 100644 (file)
@@ -8,12 +8,19 @@ import type {
   CertificateSigningUseEnumType,
   ChargingStationType,
   CustomDataType,
+  FirmwareStatusEnumType,
+  FirmwareType,
   GetCertificateIdUseEnumType,
   InstallCertificateUseEnumType,
+  LogEnumType,
+  LogParametersType,
   MessageTriggerEnumType,
+  NetworkConnectionProfileType,
   OCSPRequestDataType,
+  OperationalStatusEnumType,
   ReportBaseEnumType,
   ResetEnumType,
+  UploadLogStatusEnumType,
 } from './Common.js'
 import type {
   OCPP20ChargingProfileType,
@@ -29,26 +36,38 @@ import type {
 
 export enum OCPP20IncomingRequestCommand {
   CERTIFICATE_SIGNED = 'CertificateSigned',
+  CHANGE_AVAILABILITY = 'ChangeAvailability',
   CLEAR_CACHE = 'ClearCache',
+  CUSTOMER_INFORMATION = 'CustomerInformation',
+  DATA_TRANSFER = 'DataTransfer',
   DELETE_CERTIFICATE = 'DeleteCertificate',
   GET_BASE_REPORT = 'GetBaseReport',
   GET_INSTALLED_CERTIFICATE_IDS = 'GetInstalledCertificateIds',
+  GET_LOG = 'GetLog',
+  GET_TRANSACTION_STATUS = 'GetTransactionStatus',
   GET_VARIABLES = 'GetVariables',
   INSTALL_CERTIFICATE = 'InstallCertificate',
   REQUEST_START_TRANSACTION = 'RequestStartTransaction',
   REQUEST_STOP_TRANSACTION = 'RequestStopTransaction',
   RESET = 'Reset',
+  SET_NETWORK_PROFILE = 'SetNetworkProfile',
   SET_VARIABLES = 'SetVariables',
   TRIGGER_MESSAGE = 'TriggerMessage',
   UNLOCK_CONNECTOR = 'UnlockConnector',
+  UPDATE_FIRMWARE = 'UpdateFirmware',
 }
 
 export enum OCPP20RequestCommand {
   BOOT_NOTIFICATION = 'BootNotification',
+  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',
+  SECURITY_EVENT_NOTIFICATION = 'SecurityEventNotification',
   SIGN_CERTIFICATE = 'SignCertificate',
   STATUS_NOTIFICATION = 'StatusNotification',
   TRANSACTION_EVENT = 'TransactionEvent',
@@ -66,13 +85,42 @@ export interface OCPP20CertificateSignedRequest extends JsonObject {
   customData?: CustomDataType
 }
 
+export interface OCPP20ChangeAvailabilityRequest extends JsonObject {
+  customData?: CustomDataType
+  evse?: OCPP20EVSEType
+  operationalStatus: OperationalStatusEnumType
+}
+
 export type OCPP20ClearCacheRequest = EmptyObject
 
+export interface OCPP20CustomerInformationRequest extends JsonObject {
+  clear: boolean
+  customData?: CustomDataType
+  customerCertificate?: CertificateHashDataType
+  customerIdentifier?: string
+  idToken?: OCPP20IdTokenType
+  report: boolean
+  requestId: number
+}
+
+export interface OCPP20DataTransferRequest extends JsonObject {
+  customData?: CustomDataType
+  data?: JsonObject
+  messageId?: string
+  vendorId: string
+}
+
 export interface OCPP20DeleteCertificateRequest extends JsonObject {
   certificateHashData: CertificateHashDataType
   customData?: CustomDataType
 }
 
+export interface OCPP20FirmwareStatusNotificationRequest extends JsonObject {
+  customData?: CustomDataType
+  requestId?: number
+  status: FirmwareStatusEnumType
+}
+
 export interface OCPP20Get15118EVCertificateRequest extends JsonObject {
   action: CertificateActionEnumType
   customData?: CustomDataType
@@ -96,6 +144,20 @@ export interface OCPP20GetInstalledCertificateIdsRequest extends JsonObject {
   customData?: CustomDataType
 }
 
+export interface OCPP20GetLogRequest extends JsonObject {
+  customData?: CustomDataType
+  log: LogParametersType
+  logType: LogEnumType
+  requestId: number
+  retries?: number
+  retryInterval?: number
+}
+
+export interface OCPP20GetTransactionStatusRequest extends JsonObject {
+  customData?: CustomDataType
+  transactionId?: string
+}
+
 export interface OCPP20GetVariablesRequest extends JsonObject {
   customData?: CustomDataType
   getVariableData: OCPP20GetVariableDataType[]
@@ -109,6 +171,21 @@ export interface OCPP20InstallCertificateRequest extends JsonObject {
   customData?: CustomDataType
 }
 
+export interface OCPP20LogStatusNotificationRequest extends JsonObject {
+  customData?: CustomDataType
+  requestId?: number
+  status: UploadLogStatusEnumType
+}
+
+export interface OCPP20NotifyCustomerInformationRequest extends JsonObject {
+  customData?: CustomDataType
+  data: string
+  generatedAt: Date
+  requestId: number
+  seqNo: number
+  tbc?: boolean
+}
+
 export interface OCPP20NotifyReportRequest extends JsonObject {
   customData?: CustomDataType
   generatedAt: Date
@@ -138,6 +215,19 @@ export interface OCPP20ResetRequest extends JsonObject {
   type: ResetEnumType
 }
 
+export interface OCPP20SecurityEventNotificationRequest extends JsonObject {
+  customData?: CustomDataType
+  techInfo?: string
+  timestamp: Date
+  type: string
+}
+
+export interface OCPP20SetNetworkProfileRequest extends JsonObject {
+  configurationSlot: number
+  connectionData: NetworkConnectionProfileType
+  customData?: CustomDataType
+}
+
 export interface OCPP20SetVariablesRequest extends JsonObject {
   customData?: CustomDataType
   setVariableData: OCPP20SetVariableDataType[]
@@ -168,3 +258,11 @@ export interface OCPP20UnlockConnectorRequest extends JsonObject {
   customData?: CustomDataType
   evseId: number
 }
+
+export interface OCPP20UpdateFirmwareRequest extends JsonObject {
+  customData?: CustomDataType
+  firmware: FirmwareType
+  requestId: number
+  retries?: number
+  retryInterval?: number
+}
index 4a25491b9757ecfd62ed68ba125b88f9956be814..b8ea50001fc98df85831782203d47a62d3f19923 100644 (file)
@@ -5,7 +5,10 @@ import type { RegistrationStatusEnumType } from '../Common.js'
 import type {
   CertificateHashDataChainType,
   CertificateSignedStatusEnumType,
+  ChangeAvailabilityStatusEnumType,
   CustomDataType,
+  CustomerInformationStatusEnumType,
+  DataTransferStatusEnumType,
   DeleteCertificateStatusEnumType,
   GenericDeviceModelStatusEnumType,
   GenericStatusEnumType,
@@ -13,10 +16,13 @@ import type {
   GetInstalledCertificateStatusEnumType,
   InstallCertificateStatusEnumType,
   Iso15118EVCertificateStatusEnumType,
+  LogStatusEnumType,
   ResetStatusEnumType,
+  SetNetworkProfileStatusEnumType,
   StatusInfoType,
   TriggerMessageStatusEnumType,
   UnlockStatusEnumType,
+  UpdateFirmwareStatusEnumType,
 } from './Common.js'
 import type { RequestStartStopStatusEnumType } from './Transaction.js'
 import type { OCPP20GetVariableResultType, OCPP20SetVariableResultType } from './Variables.js'
@@ -35,18 +41,39 @@ export interface OCPP20CertificateSignedResponse extends JsonObject {
   statusInfo?: StatusInfoType
 }
 
+export interface OCPP20ChangeAvailabilityResponse extends JsonObject {
+  customData?: CustomDataType
+  status: ChangeAvailabilityStatusEnumType
+  statusInfo?: StatusInfoType
+}
+
 export interface OCPP20ClearCacheResponse extends JsonObject {
   customData?: CustomDataType
   status: GenericStatusEnumType
   statusInfo?: StatusInfoType
 }
 
+export interface OCPP20CustomerInformationResponse extends JsonObject {
+  customData?: CustomDataType
+  status: CustomerInformationStatusEnumType
+  statusInfo?: StatusInfoType
+}
+
+export interface OCPP20DataTransferResponse extends JsonObject {
+  customData?: CustomDataType
+  data?: JsonObject
+  status: DataTransferStatusEnumType
+  statusInfo?: StatusInfoType
+}
+
 export interface OCPP20DeleteCertificateResponse extends JsonObject {
   customData?: CustomDataType
   status: DeleteCertificateStatusEnumType
   statusInfo?: StatusInfoType
 }
 
+export type OCPP20FirmwareStatusNotificationResponse = EmptyObject
+
 export interface OCPP20Get15118EVCertificateResponse extends JsonObject {
   customData?: CustomDataType
   exiResponse: string
@@ -74,6 +101,19 @@ export interface OCPP20GetInstalledCertificateIdsResponse extends JsonObject {
   statusInfo?: StatusInfoType
 }
 
+export interface OCPP20GetLogResponse extends JsonObject {
+  customData?: CustomDataType
+  filename?: string
+  status: LogStatusEnumType
+  statusInfo?: StatusInfoType
+}
+
+export interface OCPP20GetTransactionStatusResponse extends JsonObject {
+  customData?: CustomDataType
+  messagesInQueue: boolean
+  ongoingIndicator?: boolean
+}
+
 export interface OCPP20GetVariablesResponse extends JsonObject {
   customData?: CustomDataType
   getVariableResult: OCPP20GetVariableResultType[]
@@ -90,6 +130,10 @@ export interface OCPP20InstallCertificateResponse extends JsonObject {
   statusInfo?: StatusInfoType
 }
 
+export type OCPP20LogStatusNotificationResponse = EmptyObject
+
+export type OCPP20NotifyCustomerInformationResponse = EmptyObject
+
 export type OCPP20NotifyReportResponse = EmptyObject
 
 export interface OCPP20RequestStartTransactionResponse extends JsonObject {
@@ -111,6 +155,14 @@ export interface OCPP20ResetResponse extends JsonObject {
   statusInfo?: StatusInfoType
 }
 
+export type OCPP20SecurityEventNotificationResponse = EmptyObject
+
+export interface OCPP20SetNetworkProfileResponse extends JsonObject {
+  customData?: CustomDataType
+  status: SetNetworkProfileStatusEnumType
+  statusInfo?: StatusInfoType
+}
+
 export interface OCPP20SetVariablesResponse extends JsonObject {
   customData?: CustomDataType
   setVariableResult: OCPP20SetVariableResultType[]
@@ -137,3 +189,9 @@ export interface OCPP20UnlockConnectorResponse extends JsonObject {
   status: UnlockStatusEnumType
   statusInfo?: StatusInfoType
 }
+
+export interface OCPP20UpdateFirmwareResponse extends JsonObject {
+  customData?: CustomDataType
+  status: UpdateFirmwareStatusEnumType
+  statusInfo?: StatusInfoType
+}
diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ChangeAvailability.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ChangeAvailability.test.ts
new file mode 100644 (file)
index 0000000..5b3262f
--- /dev/null
@@ -0,0 +1,165 @@
+/**
+ * @file Tests for OCPP20IncomingRequestService ChangeAvailability
+ * @description Unit tests for OCPP 2.0.1 ChangeAvailability command handling (G03)
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+
+import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
+import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import {
+  ChangeAvailabilityStatusEnumType,
+  OCPPVersion,
+  OperationalStatusEnumType,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import {
+  setupConnectorWithTransaction,
+  standardCleanup,
+} from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+
+await describe('G03 - ChangeAvailability', async () => {
+  let station: ChargingStation
+  let testableService: ReturnType<typeof createTestableIncomingRequestService>
+
+  beforeEach(() => {
+    const { station: mockStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 3,
+      evseConfiguration: { evsesCount: 3 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      ocppRequestService: {
+        requestHandler: async () => await Promise.resolve({}),
+      },
+      stationInfo: {
+        ocppStrictCompliance: false,
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    station = mockStation
+    const incomingRequestService = new OCPP20IncomingRequestService()
+    testableService = createTestableIncomingRequestService(incomingRequestService)
+  })
+
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  // FR: G03.FR.01
+  await it('should accept EVSE-level Inoperative when no ongoing transaction', () => {
+    const response = testableService.handleRequestChangeAvailability(station, {
+      evse: { id: 1 },
+      operationalStatus: OperationalStatusEnumType.Inoperative,
+    })
+
+    assert.strictEqual(response.status, ChangeAvailabilityStatusEnumType.Accepted)
+    const evseStatus = station.getEvseStatus(1)
+    assert.strictEqual(evseStatus?.availability, OperationalStatusEnumType.Inoperative)
+  })
+
+  // FR: G03.FR.02
+  await it('should accept CS-level Inoperative when no ongoing transaction', () => {
+    const response = testableService.handleRequestChangeAvailability(station, {
+      operationalStatus: OperationalStatusEnumType.Inoperative,
+    })
+
+    assert.strictEqual(response.status, ChangeAvailabilityStatusEnumType.Accepted)
+    for (const [evseId, evseStatus] of station.evses) {
+      if (evseId > 0) {
+        assert.strictEqual(
+          evseStatus.availability,
+          OperationalStatusEnumType.Inoperative,
+          `EVSE ${String(evseId)} should be Inoperative`
+        )
+      }
+    }
+  })
+
+  // FR: G03.FR.03
+  await it('should schedule EVSE-level Inoperative when ongoing transaction exists', () => {
+    setupConnectorWithTransaction(station, 1, {
+      transactionId: 100,
+    })
+
+    const response = testableService.handleRequestChangeAvailability(station, {
+      evse: { id: 1 },
+      operationalStatus: OperationalStatusEnumType.Inoperative,
+    })
+
+    assert.strictEqual(response.status, ChangeAvailabilityStatusEnumType.Scheduled)
+  })
+
+  // FR: G03.FR.04
+  await it('should schedule CS-level Inoperative when some EVSEs have transactions', () => {
+    setupConnectorWithTransaction(station, 2, {
+      transactionId: 200,
+    })
+
+    const response = testableService.handleRequestChangeAvailability(station, {
+      operationalStatus: OperationalStatusEnumType.Inoperative,
+    })
+
+    assert.strictEqual(response.status, ChangeAvailabilityStatusEnumType.Scheduled)
+  })
+
+  await it('should reject when EVSE does not exist', () => {
+    const response = testableService.handleRequestChangeAvailability(station, {
+      evse: { id: 999 },
+      operationalStatus: OperationalStatusEnumType.Inoperative,
+    })
+
+    assert.strictEqual(response.status, ChangeAvailabilityStatusEnumType.Rejected)
+    assert.notStrictEqual(response.statusInfo, undefined)
+    assert.strictEqual(response.statusInfo?.reasonCode, 'UnknownEvse')
+  })
+
+  await it('should accept when already in requested state (idempotent)', () => {
+    const evseStatus = station.getEvseStatus(1)
+    if (evseStatus != null) {
+      evseStatus.availability = OperationalStatusEnumType.Operative
+    }
+
+    const response = testableService.handleRequestChangeAvailability(station, {
+      evse: { id: 1 },
+      operationalStatus: OperationalStatusEnumType.Operative,
+    })
+
+    assert.strictEqual(response.status, ChangeAvailabilityStatusEnumType.Accepted)
+    assert.strictEqual(evseStatus?.availability, OperationalStatusEnumType.Operative)
+  })
+
+  await it('should set Operative after Inoperative, connectors return to Available', () => {
+    const evseStatus = station.getEvseStatus(1)
+    if (evseStatus != null) {
+      evseStatus.availability = OperationalStatusEnumType.Inoperative
+    }
+
+    const response = testableService.handleRequestChangeAvailability(station, {
+      evse: { id: 1 },
+      operationalStatus: OperationalStatusEnumType.Operative,
+    })
+
+    assert.strictEqual(response.status, ChangeAvailabilityStatusEnumType.Accepted)
+    assert.strictEqual(evseStatus?.availability, OperationalStatusEnumType.Operative)
+  })
+
+  await it('should accept CS-level change with evse.id === 0', () => {
+    const response = testableService.handleRequestChangeAvailability(station, {
+      evse: { id: 0 },
+      operationalStatus: OperationalStatusEnumType.Inoperative,
+    })
+
+    assert.strictEqual(response.status, ChangeAvailabilityStatusEnumType.Accepted)
+    for (const [evseId, evseStatus] of station.evses) {
+      if (evseId > 0) {
+        assert.strictEqual(evseStatus.availability, OperationalStatusEnumType.Inoperative)
+      }
+    }
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CustomerInformation.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CustomerInformation.test.ts
new file mode 100644 (file)
index 0000000..bd802e1
--- /dev/null
@@ -0,0 +1,106 @@
+/**
+ * @file Tests for OCPP20IncomingRequestService CustomerInformation
+ * @description Unit tests for OCPP 2.0.1 CustomerInformation command handling
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+
+import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
+import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import { CustomerInformationStatusEnumType, OCPPVersion } from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+
+await describe('N32 - CustomerInformation', async () => {
+  let station: ChargingStation
+  let testableService: ReturnType<typeof createTestableIncomingRequestService>
+
+  beforeEach(() => {
+    const { station: mockStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 3,
+      evseConfiguration: { evsesCount: 3 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      stationInfo: {
+        ocppStrictCompliance: false,
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    station = mockStation
+    testableService = createTestableIncomingRequestService(new OCPP20IncomingRequestService())
+  })
+
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  // TC_N_32_CS: CS must respond to CustomerInformation with Accepted for clear requests
+  await it('should respond with Accepted status for clear request', () => {
+    const response = testableService.handleRequestCustomerInformation(station, {
+      clear: true,
+      report: false,
+      requestId: 1,
+    })
+
+    assert.notStrictEqual(response, undefined)
+    assert.strictEqual(typeof response, 'object')
+    assert.notStrictEqual(response.status, undefined)
+    assert.strictEqual(response.status, CustomerInformationStatusEnumType.Accepted)
+  })
+
+  // TC_N_32_CS: CS must respond to CustomerInformation with Accepted for report requests
+  await it('should respond with Accepted status for report request', () => {
+    const response = testableService.handleRequestCustomerInformation(station, {
+      clear: false,
+      report: true,
+      requestId: 2,
+    })
+
+    assert.notStrictEqual(response, undefined)
+    assert.strictEqual(typeof response, 'object')
+    assert.notStrictEqual(response.status, undefined)
+    assert.strictEqual(response.status, CustomerInformationStatusEnumType.Accepted)
+  })
+
+  // TC_N_32_CS: CS must respond with Rejected when neither clear nor report is set
+  await it('should respond with Rejected status when neither clear nor report is set', () => {
+    const response = testableService.handleRequestCustomerInformation(station, {
+      clear: false,
+      report: false,
+      requestId: 3,
+    })
+
+    assert.notStrictEqual(response, undefined)
+    assert.strictEqual(typeof response, 'object')
+    assert.notStrictEqual(response.status, undefined)
+    assert.strictEqual(response.status, CustomerInformationStatusEnumType.Rejected)
+  })
+
+  // Verify clear request with explicit false report flag
+  await it('should respond with Accepted for clear=true and report=false', () => {
+    const response = testableService.handleRequestCustomerInformation(station, {
+      clear: true,
+      report: false,
+      requestId: 4,
+    })
+
+    assert.strictEqual(response.status, CustomerInformationStatusEnumType.Accepted)
+  })
+
+  // Verify report request with explicit false clear flag
+  await it('should respond with Accepted for clear=false and report=true', () => {
+    const response = testableService.handleRequestCustomerInformation(station, {
+      clear: false,
+      report: true,
+      requestId: 5,
+    })
+
+    assert.strictEqual(response.status, CustomerInformationStatusEnumType.Accepted)
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-DataTransfer.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-DataTransfer.test.ts
new file mode 100644 (file)
index 0000000..24a8fdb
--- /dev/null
@@ -0,0 +1,64 @@
+/**
+ * @file Tests for OCPP20IncomingRequestService DataTransfer
+ * @description Unit tests for OCPP 2.0 DataTransfer command handling
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+
+import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
+import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import { DataTransferStatusEnumType, OCPPVersion } from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+
+await describe('P01 - DataTransfer', async () => {
+  let station: ChargingStation
+  let testableService: ReturnType<typeof createTestableIncomingRequestService>
+
+  beforeEach(() => {
+    const { station: mockStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 3,
+      evseConfiguration: { evsesCount: 3 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      stationInfo: {
+        ocppStrictCompliance: false,
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    station = mockStation
+    testableService = createTestableIncomingRequestService(new OCPP20IncomingRequestService())
+  })
+
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  // TC_P_01_CS: CS with no vendor extensions must respond UnknownVendorId
+  await it('should respond with UnknownVendorId status', () => {
+    const response = testableService.handleRequestDataTransfer(station, {
+      vendorId: 'TestVendor',
+    })
+
+    assert.notStrictEqual(response, undefined)
+    assert.strictEqual(typeof response, 'object')
+    assert.notStrictEqual(response.status, undefined)
+    assert.strictEqual(response.status, DataTransferStatusEnumType.UnknownVendorId)
+  })
+
+  // TC_P_01_CS: Verify response is UnknownVendorId regardless of vendorId value
+  await it('should respond with UnknownVendorId regardless of vendorId', () => {
+    const response = testableService.handleRequestDataTransfer(station, {
+      vendorId: 'AnotherVendor',
+    })
+
+    assert.notStrictEqual(response, undefined)
+    assert.strictEqual(response.status, DataTransferStatusEnumType.UnknownVendorId)
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts
new file mode 100644 (file)
index 0000000..bc86706
--- /dev/null
@@ -0,0 +1,115 @@
+/**
+ * @file Tests for OCPP20IncomingRequestService GetLog
+ * @description Unit tests for OCPP 2.0.1 GetLog command handling (K01)
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+
+import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
+import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import {
+  LogEnumType,
+  LogStatusEnumType,
+  type OCPP20GetLogRequest,
+  OCPPVersion,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+
+await describe('K01 - GetLog', async () => {
+  let station: ChargingStation
+  let testableService: ReturnType<typeof createTestableIncomingRequestService>
+
+  beforeEach(() => {
+    const { station: mockStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 1,
+      evseConfiguration: { evsesCount: 1 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      stationInfo: {
+        ocppStrictCompliance: false,
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    station = mockStation
+    testableService = createTestableIncomingRequestService(new OCPP20IncomingRequestService())
+  })
+
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await it('should return Accepted with filename for DiagnosticsLog request', () => {
+    const request: OCPP20GetLogRequest = {
+      log: {
+        remoteLocation: 'ftp://logs.example.com/uploads/',
+      },
+      logType: LogEnumType.DiagnosticsLog,
+      requestId: 1,
+    }
+
+    const response = testableService.handleRequestGetLog(station, request)
+
+    assert.notStrictEqual(response, undefined)
+    assert.strictEqual(typeof response, 'object')
+    assert.strictEqual(response.status, LogStatusEnumType.Accepted)
+    assert.strictEqual(response.filename, 'simulator-log.txt')
+  })
+
+  await it('should return Accepted with filename for SecurityLog request', () => {
+    const request: OCPP20GetLogRequest = {
+      log: {
+        remoteLocation: 'https://logs.example.com/security/',
+      },
+      logType: LogEnumType.SecurityLog,
+      requestId: 2,
+    }
+
+    const response = testableService.handleRequestGetLog(station, request)
+
+    assert.strictEqual(response.status, LogStatusEnumType.Accepted)
+    assert.strictEqual(response.filename, 'simulator-log.txt')
+  })
+
+  await it('should pass through requestId correctly across different values', () => {
+    const testRequestId = 42
+    const request: OCPP20GetLogRequest = {
+      log: {
+        remoteLocation: 'ftp://logs.example.com/uploads/',
+      },
+      logType: LogEnumType.DiagnosticsLog,
+      requestId: testRequestId,
+    }
+
+    const response = testableService.handleRequestGetLog(station, request)
+
+    assert.strictEqual(response.status, LogStatusEnumType.Accepted)
+    assert.strictEqual(typeof response.status, 'string')
+    assert.strictEqual(response.filename, 'simulator-log.txt')
+  })
+
+  await it('should return Accepted for request with retries and retryInterval', () => {
+    const request: OCPP20GetLogRequest = {
+      log: {
+        latestTimestamp: new Date('2025-01-15T23:59:59.000Z'),
+        oldestTimestamp: new Date('2025-01-01T00:00:00.000Z'),
+        remoteLocation: 'ftp://logs.example.com/uploads/',
+      },
+      logType: LogEnumType.DiagnosticsLog,
+      requestId: 5,
+      retries: 3,
+      retryInterval: 60,
+    }
+
+    const response = testableService.handleRequestGetLog(station, request)
+
+    assert.strictEqual(response.status, LogStatusEnumType.Accepted)
+    assert.strictEqual(response.filename, 'simulator-log.txt')
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetTransactionStatus.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetTransactionStatus.test.ts
new file mode 100644 (file)
index 0000000..f2962d2
--- /dev/null
@@ -0,0 +1,96 @@
+/**
+ * @file Tests for OCPP20IncomingRequestService GetTransactionStatus
+ * @description Unit tests for OCPP 2.0.1 GetTransactionStatus command handling
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+
+import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
+import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import { OCPPVersion } from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import {
+  setupConnectorWithTransaction,
+  standardCleanup,
+} from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+
+await describe('D14 - GetTransactionStatus', async () => {
+  let station: ChargingStation
+  let testableService: ReturnType<typeof createTestableIncomingRequestService>
+
+  beforeEach(() => {
+    const { station: mockStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 3,
+      evseConfiguration: { evsesCount: 3 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      stationInfo: {
+        ocppStrictCompliance: false,
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    station = mockStation
+    testableService = createTestableIncomingRequestService(new OCPP20IncomingRequestService())
+  })
+
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  // E14.FR.06: When no transactionId provided, ongoingIndicator SHALL NOT be set
+  await it('should not include ongoingIndicator when no transactionId provided (E14.FR.06)', () => {
+    const response = testableService.handleRequestGetTransactionStatus(station, {})
+
+    assert.notStrictEqual(response, undefined)
+    assert.strictEqual(typeof response, 'object')
+    assert.strictEqual(response.ongoingIndicator, undefined)
+    assert.strictEqual(response.messagesInQueue, false)
+  })
+
+  // E14.FR.06: Even with active transactions, no transactionId → ongoingIndicator not set
+  await it('should not include ongoingIndicator when active transaction exists but no transactionId (E14.FR.06)', () => {
+    const transactionId = 'txn-12345'
+    setupConnectorWithTransaction(station, 1, {
+      transactionId: transactionId as unknown as number,
+    })
+
+    const response = testableService.handleRequestGetTransactionStatus(station, {})
+
+    assert.notStrictEqual(response, undefined)
+    assert.strictEqual(response.ongoingIndicator, undefined)
+    assert.strictEqual(response.messagesInQueue, false)
+  })
+
+  // E14.FR.01: Unknown transactionId → ongoingIndicator: false
+  await it('should return ongoingIndicator false when specific transactionId does not exist', () => {
+    const response = testableService.handleRequestGetTransactionStatus(station, {
+      transactionId: 'nonexistent-txn-id',
+    })
+
+    assert.notStrictEqual(response, undefined)
+    assert.strictEqual(response.ongoingIndicator, false)
+    assert.strictEqual(response.messagesInQueue, false)
+  })
+
+  // E14.FR.02: Active transaction with transactionId → ongoingIndicator: true
+  await it('should return ongoingIndicator true when specific transactionId exists', () => {
+    const transactionId = 'txn-67890'
+    setupConnectorWithTransaction(station, 2, {
+      transactionId: transactionId as unknown as number,
+    })
+
+    const response = testableService.handleRequestGetTransactionStatus(station, {
+      transactionId,
+    })
+
+    assert.notStrictEqual(response, undefined)
+    assert.strictEqual(response.ongoingIndicator, true)
+    assert.strictEqual(response.messagesInQueue, false)
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-SetNetworkProfile.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-SetNetworkProfile.test.ts
new file mode 100644 (file)
index 0000000..610629e
--- /dev/null
@@ -0,0 +1,91 @@
+/**
+ * @file Tests for OCPP20IncomingRequestService SetNetworkProfile
+ * @description Unit tests for OCPP 2.0.1 SetNetworkProfile command handling
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+
+import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
+import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import {
+  OCPPVersion,
+  ReasonCodeEnumType,
+  SetNetworkProfileStatusEnumType,
+} from '../../../../src/types/index.js'
+import {
+  OCPPInterfaceEnumType,
+  OCPPTransportEnumType,
+  OCPPVersionEnumType,
+} from '../../../../src/types/ocpp/2.0/Common.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+
+await describe('B43 - SetNetworkProfile', async () => {
+  let station: ChargingStation
+  let testableService: ReturnType<typeof createTestableIncomingRequestService>
+
+  beforeEach(() => {
+    const { station: mockStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 3,
+      evseConfiguration: { evsesCount: 3 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      stationInfo: {
+        ocppStrictCompliance: false,
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    station = mockStation
+    testableService = createTestableIncomingRequestService(new OCPP20IncomingRequestService())
+  })
+
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  // TC_B_43_CS: CS must respond to SetNetworkProfile at minimum with Rejected
+  await it('should respond with Rejected status', () => {
+    const response = testableService.handleRequestSetNetworkProfile(station, {
+      configurationSlot: 1,
+      connectionData: {
+        messageTimeout: 30,
+        ocppCsmsUrl: 'wss://example.com/ocpp',
+        ocppInterface: OCPPInterfaceEnumType.Wired0,
+        ocppTransport: OCPPTransportEnumType.JSON,
+        ocppVersion: OCPPVersionEnumType.OCPP20,
+        securityProfile: 3,
+      },
+    })
+
+    assert.notStrictEqual(response, undefined)
+    assert.strictEqual(typeof response, 'object')
+    assert.notStrictEqual(response.status, undefined)
+    assert.strictEqual(response.status, SetNetworkProfileStatusEnumType.Rejected)
+  })
+
+  // TC_B_43_CS: Verify response includes statusInfo with reasonCode
+  await it('should include statusInfo with UnsupportedRequest reasonCode', () => {
+    const response = testableService.handleRequestSetNetworkProfile(station, {
+      configurationSlot: 1,
+      connectionData: {
+        messageTimeout: 30,
+        ocppCsmsUrl: 'wss://example.com/ocpp',
+        ocppInterface: OCPPInterfaceEnumType.Wired0,
+        ocppTransport: OCPPTransportEnumType.JSON,
+        ocppVersion: OCPPVersionEnumType.OCPP20,
+        securityProfile: 3,
+      },
+    })
+
+    assert.notStrictEqual(response, undefined)
+    assert.strictEqual(response.status, SetNetworkProfileStatusEnumType.Rejected)
+    assert.notStrictEqual(response.statusInfo, undefined)
+    assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.UnsupportedRequest)
+  })
+})
index 3c9cf8a357ff1907e546de8326946e6b2549daff..93dc0e7922eac037c2da963131e2142a6bf977e2 100644 (file)
@@ -149,17 +149,24 @@ await describe('F06 - TriggerMessage', async () => {
 
       assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
     })
-  })
 
-  await describe('F06 - NotImplemented triggers', async () => {
-    let mockStation: MockChargingStation
+    await it('should return Accepted for MeterValues trigger', () => {
+      const request: OCPP20TriggerMessageRequest = {
+        requestedMessage: MessageTriggerEnumType.MeterValues,
+      }
 
-    beforeEach(() => {
-      ;({ mockStation } = createTriggerMessageStation())
+      const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+        mockStation,
+        request
+      )
+
+      assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+      assert.strictEqual(response.statusInfo, undefined)
     })
 
-    await it('should return NotImplemented for MeterValues trigger', () => {
+    await it('should return Accepted for MeterValues trigger with specific EVSE', () => {
       const request: OCPP20TriggerMessageRequest = {
+        evse: { id: 1 },
         requestedMessage: MessageTriggerEnumType.MeterValues,
       }
 
@@ -168,15 +175,44 @@ await describe('F06 - TriggerMessage', async () => {
         request
       )
 
-      assert.strictEqual(response.status, TriggerMessageStatusEnumType.NotImplemented)
-      if (response.statusInfo == null) {
-        assert.fail('Expected statusInfo to be defined')
+      assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+      assert.strictEqual(response.statusInfo, undefined)
+    })
+
+    await it('should return Accepted for FirmwareStatusNotification trigger', () => {
+      const request: OCPP20TriggerMessageRequest = {
+        requestedMessage: MessageTriggerEnumType.FirmwareStatusNotification,
       }
-      assert.strictEqual(response.statusInfo.reasonCode, ReasonCodeEnumType.UnsupportedRequest)
-      if (response.statusInfo.additionalInfo == null) {
-        assert.fail('Expected additionalInfo to be defined')
+
+      const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+        mockStation,
+        request
+      )
+
+      assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+      assert.strictEqual(response.statusInfo, undefined)
+    })
+
+    await it('should return Accepted for LogStatusNotification trigger', () => {
+      const request: OCPP20TriggerMessageRequest = {
+        requestedMessage: MessageTriggerEnumType.LogStatusNotification,
       }
-      assert.ok(response.statusInfo.additionalInfo.includes('MeterValues'))
+
+      const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+        mockStation,
+        request
+      )
+
+      assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+      assert.strictEqual(response.statusInfo, undefined)
+    })
+  })
+
+  await describe('F06 - NotImplemented triggers', async () => {
+    let mockStation: MockChargingStation
+
+    beforeEach(() => {
+      ;({ mockStation } = createTriggerMessageStation())
     })
 
     await it('should return NotImplemented for TransactionEvent trigger', () => {
@@ -194,9 +230,9 @@ await describe('F06 - TriggerMessage', async () => {
       assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.UnsupportedRequest)
     })
 
-    await it('should return NotImplemented for LogStatusNotification trigger', () => {
+    await it('should return NotImplemented for PublishFirmwareStatusNotification trigger', () => {
       const request: OCPP20TriggerMessageRequest = {
-        requestedMessage: MessageTriggerEnumType.LogStatusNotification,
+        requestedMessage: MessageTriggerEnumType.PublishFirmwareStatusNotification,
       }
 
       const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
@@ -209,9 +245,9 @@ await describe('F06 - TriggerMessage', async () => {
       assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.UnsupportedRequest)
     })
 
-    await it('should return NotImplemented for FirmwareStatusNotification trigger', () => {
+    await it('should return NotImplemented for SignChargingStationCertificate trigger', () => {
       const request: OCPP20TriggerMessageRequest = {
-        requestedMessage: MessageTriggerEnumType.FirmwareStatusNotification,
+        requestedMessage: MessageTriggerEnumType.SignChargingStationCertificate,
       }
 
       const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts
new file mode 100644 (file)
index 0000000..c9b5bb7
--- /dev/null
@@ -0,0 +1,125 @@
+/**
+ * @file Tests for OCPP20IncomingRequestService UpdateFirmware
+ * @description Unit tests for OCPP 2.0.1 UpdateFirmware command handling (J02)
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+
+import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
+import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import {
+  type OCPP20UpdateFirmwareRequest,
+  OCPPVersion,
+  UpdateFirmwareStatusEnumType,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+
+await describe('J02 - UpdateFirmware', async () => {
+  let station: ChargingStation
+  let testableService: ReturnType<typeof createTestableIncomingRequestService>
+
+  beforeEach(() => {
+    const { station: mockStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 1,
+      evseConfiguration: { evsesCount: 1 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      stationInfo: {
+        ocppStrictCompliance: false,
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    station = mockStation
+    testableService = createTestableIncomingRequestService(new OCPP20IncomingRequestService())
+  })
+
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await it('should return Accepted for valid firmware update request', () => {
+    const request: OCPP20UpdateFirmwareRequest = {
+      firmware: {
+        location: 'https://firmware.example.com/update-v2.0.bin',
+        retrieveDateTime: new Date('2025-01-15T10:00:00.000Z'),
+      },
+      requestId: 1,
+    }
+
+    const response = testableService.handleRequestUpdateFirmware(station, request)
+
+    assert.notStrictEqual(response, undefined)
+    assert.strictEqual(typeof response, 'object')
+    assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted)
+  })
+
+  await it('should return Accepted for request with signature field', () => {
+    const request: OCPP20UpdateFirmwareRequest = {
+      firmware: {
+        location: 'https://firmware.example.com/signed-update.bin',
+        retrieveDateTime: new Date('2025-01-15T10:00:00.000Z'),
+        signature: 'dGVzdC1zaWduYXR1cmU=',
+        signingCertificate: '-----BEGIN CERTIFICATE-----\nMIIBkTCB...',
+      },
+      requestId: 2,
+    }
+
+    const response = testableService.handleRequestUpdateFirmware(station, request)
+
+    assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted)
+  })
+
+  await it('should return Accepted for request without signature', () => {
+    const request: OCPP20UpdateFirmwareRequest = {
+      firmware: {
+        location: 'https://firmware.example.com/unsigned-update.bin',
+        retrieveDateTime: new Date('2025-01-15T12:00:00.000Z'),
+      },
+      requestId: 3,
+    }
+
+    const response = testableService.handleRequestUpdateFirmware(station, request)
+
+    assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted)
+  })
+
+  await it('should pass through requestId correctly in the response', () => {
+    const testRequestId = 42
+    const request: OCPP20UpdateFirmwareRequest = {
+      firmware: {
+        location: 'https://firmware.example.com/update.bin',
+        retrieveDateTime: new Date('2025-01-15T14:00:00.000Z'),
+      },
+      requestId: testRequestId,
+    }
+
+    const response = testableService.handleRequestUpdateFirmware(station, request)
+
+    assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted)
+    assert.strictEqual(typeof response.status, 'string')
+  })
+
+  await it('should return Accepted for request with retries and retryInterval', () => {
+    const request: OCPP20UpdateFirmwareRequest = {
+      firmware: {
+        installDateTime: new Date('2025-01-15T16:00:00.000Z'),
+        location: 'https://firmware.example.com/update-retry.bin',
+        retrieveDateTime: new Date('2025-01-15T15:00:00.000Z'),
+      },
+      requestId: 5,
+      retries: 3,
+      retryInterval: 60,
+    }
+
+    const response = testableService.handleRequestUpdateFirmware(station, request)
+
+    assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted)
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-FirmwareStatusNotification.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-FirmwareStatusNotification.test.ts
new file mode 100644 (file)
index 0000000..e279940
--- /dev/null
@@ -0,0 +1,99 @@
+/**
+ * @file Tests for OCPP20RequestService FirmwareStatusNotification
+ * @description Unit tests for OCPP 2.0.1 FirmwareStatusNotification outgoing command (J01)
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+
+import {
+  createTestableRequestService,
+  type SendMessageMock,
+  type TestableOCPP20RequestService,
+} from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
+import {
+  FirmwareStatusEnumType,
+  type OCPP20FirmwareStatusNotificationRequest,
+  type OCPP20FirmwareStatusNotificationResponse,
+  OCPP20RequestCommand,
+  OCPPVersion,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+
+await describe('J01 - FirmwareStatusNotification', async () => {
+  let station: ChargingStation
+  let sendMessageMock: SendMessageMock
+  let service: TestableOCPP20RequestService
+
+  beforeEach(() => {
+    const { station: newStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 1,
+      evseConfiguration: { evsesCount: 1 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      stationInfo: {
+        ocppStrictCompliance: false,
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    station = newStation
+
+    const testable = createTestableRequestService<OCPP20FirmwareStatusNotificationResponse>({
+      sendMessageResponse: {},
+    })
+    sendMessageMock = testable.sendMessageMock
+    service = testable.service
+  })
+
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await it('should send FirmwareStatusNotification with Downloading status', async () => {
+    await service.requestFirmwareStatusNotification(station, FirmwareStatusEnumType.Downloading, 42)
+
+    assert.strictEqual(sendMessageMock.mock.calls.length, 1)
+
+    const sentPayload = sendMessageMock.mock.calls[0]
+      .arguments[2] as OCPP20FirmwareStatusNotificationRequest
+    assert.strictEqual(sentPayload.status, FirmwareStatusEnumType.Downloading)
+    assert.strictEqual(sentPayload.requestId, 42)
+
+    const commandName = sendMessageMock.mock.calls[0].arguments[3]
+    assert.strictEqual(commandName, OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION)
+  })
+
+  await it('should include requestId when provided', async () => {
+    const testRequestId = 99
+
+    await service.requestFirmwareStatusNotification(
+      station,
+      FirmwareStatusEnumType.Installed,
+      testRequestId
+    )
+
+    assert.strictEqual(sendMessageMock.mock.calls.length, 1)
+
+    const sentPayload = sendMessageMock.mock.calls[0]
+      .arguments[2] as OCPP20FirmwareStatusNotificationRequest
+    assert.strictEqual(sentPayload.status, FirmwareStatusEnumType.Installed)
+    assert.strictEqual(sentPayload.requestId, testRequestId)
+  })
+
+  await it('should return empty response from CSMS', async () => {
+    const response = await service.requestFirmwareStatusNotification(
+      station,
+      FirmwareStatusEnumType.Downloaded,
+      1
+    )
+
+    assert.notStrictEqual(response, undefined)
+    assert.strictEqual(typeof response, 'object')
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-LogStatusNotification.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-LogStatusNotification.test.ts
new file mode 100644 (file)
index 0000000..3ea8f38
--- /dev/null
@@ -0,0 +1,99 @@
+/**
+ * @file Tests for OCPP20RequestService LogStatusNotification
+ * @description Unit tests for OCPP 2.0.1 LogStatusNotification outgoing command (M04)
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+
+import {
+  createTestableRequestService,
+  type SendMessageMock,
+  type TestableOCPP20RequestService,
+} from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
+import {
+  type OCPP20LogStatusNotificationRequest,
+  type OCPP20LogStatusNotificationResponse,
+  OCPP20RequestCommand,
+  OCPPVersion,
+  UploadLogStatusEnumType,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+
+await describe('M04 - LogStatusNotification', async () => {
+  let station: ChargingStation
+  let sendMessageMock: SendMessageMock
+  let service: TestableOCPP20RequestService
+
+  beforeEach(() => {
+    const { station: newStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 1,
+      evseConfiguration: { evsesCount: 1 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      stationInfo: {
+        ocppStrictCompliance: false,
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    station = newStation
+
+    const testable = createTestableRequestService<OCPP20LogStatusNotificationResponse>({
+      sendMessageResponse: {},
+    })
+    sendMessageMock = testable.sendMessageMock
+    service = testable.service
+  })
+
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await it('should send LogStatusNotification with Uploading status', async () => {
+    await service.requestLogStatusNotification(station, UploadLogStatusEnumType.Uploading, 42)
+
+    assert.strictEqual(sendMessageMock.mock.calls.length, 1)
+
+    const sentPayload = sendMessageMock.mock.calls[0]
+      .arguments[2] as OCPP20LogStatusNotificationRequest
+    assert.strictEqual(sentPayload.status, UploadLogStatusEnumType.Uploading)
+    assert.strictEqual(sentPayload.requestId, 42)
+
+    const commandName = sendMessageMock.mock.calls[0].arguments[3]
+    assert.strictEqual(commandName, OCPP20RequestCommand.LOG_STATUS_NOTIFICATION)
+  })
+
+  await it('should include requestId when provided', async () => {
+    const testRequestId = 99
+
+    await service.requestLogStatusNotification(
+      station,
+      UploadLogStatusEnumType.Uploaded,
+      testRequestId
+    )
+
+    assert.strictEqual(sendMessageMock.mock.calls.length, 1)
+
+    const sentPayload = sendMessageMock.mock.calls[0]
+      .arguments[2] as OCPP20LogStatusNotificationRequest
+    assert.strictEqual(sentPayload.status, UploadLogStatusEnumType.Uploaded)
+    assert.strictEqual(sentPayload.requestId, testRequestId)
+  })
+
+  await it('should return empty response from CSMS', async () => {
+    const response = await service.requestLogStatusNotification(
+      station,
+      UploadLogStatusEnumType.Uploading,
+      1
+    )
+
+    assert.notStrictEqual(response, undefined)
+    assert.strictEqual(typeof response, 'object')
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-MeterValues.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-MeterValues.test.ts
new file mode 100644 (file)
index 0000000..795cbb9
--- /dev/null
@@ -0,0 +1,122 @@
+/**
+ * @file Tests for OCPP20RequestService MeterValues
+ * @description Unit tests for OCPP 2.0.1 MeterValues outgoing command (G01)
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+
+import {
+  createTestableRequestService,
+  type SendMessageMock,
+  type TestableOCPP20RequestService,
+} from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
+import {
+  type OCPP20MeterValuesRequest,
+  type OCPP20MeterValuesResponse,
+  OCPP20RequestCommand,
+  OCPPVersion,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+
+await describe('G01 - MeterValues', async () => {
+  let station: ChargingStation
+  let sendMessageMock: SendMessageMock
+  let service: TestableOCPP20RequestService
+
+  beforeEach(() => {
+    const { station: newStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 1,
+      evseConfiguration: { evsesCount: 1 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      stationInfo: {
+        ocppStrictCompliance: false,
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    station = newStation
+
+    const testable = createTestableRequestService<OCPP20MeterValuesResponse>({
+      sendMessageResponse: {},
+    })
+    sendMessageMock = testable.sendMessageMock
+    service = testable.service
+  })
+
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await it('should send MeterValues with valid EVSE ID and meter values', async () => {
+    const testTimestamp = new Date('2024-06-01T12:00:00.000Z')
+    const evseId = 1
+    const meterValue = [
+      {
+        sampledValue: [{ value: 1500.5 }],
+        timestamp: testTimestamp,
+      },
+    ]
+
+    await service.requestMeterValues(station, evseId, meterValue)
+
+    assert.strictEqual(sendMessageMock.mock.calls.length, 1)
+
+    const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20MeterValuesRequest
+    assert.strictEqual(sentPayload.evseId, evseId)
+    assert.strictEqual(sentPayload.meterValue.length, 1)
+    assert.strictEqual(sentPayload.meterValue[0].sampledValue[0].value, 1500.5)
+    assert.strictEqual(sentPayload.meterValue[0].timestamp, testTimestamp)
+
+    const commandName = sendMessageMock.mock.calls[0].arguments[3]
+    assert.strictEqual(commandName, OCPP20RequestCommand.METER_VALUES)
+  })
+
+  await it('should send MeterValues with multiple sampledValue entries', async () => {
+    const testTimestamp = new Date('2024-06-01T12:05:00.000Z')
+    const evseId = 2
+    const meterValue = [
+      {
+        sampledValue: [{ value: 230.1 }, { value: 16.0 }, { value: 3680.0 }],
+        timestamp: testTimestamp,
+      },
+    ]
+
+    await service.requestMeterValues(station, evseId, meterValue)
+
+    assert.strictEqual(sendMessageMock.mock.calls.length, 1)
+
+    const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20MeterValuesRequest
+    assert.strictEqual(sentPayload.meterValue[0].sampledValue.length, 3)
+    assert.strictEqual(sentPayload.meterValue[0].sampledValue[0].value, 230.1)
+    assert.strictEqual(sentPayload.meterValue[0].sampledValue[1].value, 16.0)
+    assert.strictEqual(sentPayload.meterValue[0].sampledValue[2].value, 3680.0)
+  })
+
+  await it('should set evseId correctly including zero for main power meter', async () => {
+    const testTimestamp = new Date('2024-06-01T12:10:00.000Z')
+    const meterValue = [
+      {
+        sampledValue: [{ value: 50000.0 }],
+        timestamp: testTimestamp,
+      },
+    ]
+
+    // evseId 0 = main power meter
+    const response = await service.requestMeterValues(station, 0, meterValue)
+
+    assert.strictEqual(sendMessageMock.mock.calls.length, 1)
+
+    const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20MeterValuesRequest
+    assert.strictEqual(sentPayload.evseId, 0)
+
+    assert.notStrictEqual(response, undefined)
+    assert.strictEqual(typeof response, 'object')
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-SecurityEventNotification.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-SecurityEventNotification.test.ts
new file mode 100644 (file)
index 0000000..3ec59e9
--- /dev/null
@@ -0,0 +1,103 @@
+/**
+ * @file Tests for OCPP20RequestService SecurityEventNotification
+ * @description Unit tests for OCPP 2.0 SecurityEventNotification outgoing command (A04)
+ */
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+
+import {
+  createTestableRequestService,
+  type SendMessageMock,
+  type TestableOCPP20RequestService,
+} from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
+import {
+  OCPP20RequestCommand,
+  type OCPP20SecurityEventNotificationRequest,
+  type OCPP20SecurityEventNotificationResponse,
+  OCPPVersion,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+
+await describe('A04 - SecurityEventNotification', async () => {
+  let station: ChargingStation
+  let sendMessageMock: SendMessageMock
+  let service: TestableOCPP20RequestService
+
+  beforeEach(() => {
+    const { station: newStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 1,
+      evseConfiguration: { evsesCount: 1 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      stationInfo: {
+        ocppStrictCompliance: false,
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    station = newStation
+
+    const testable = createTestableRequestService<OCPP20SecurityEventNotificationResponse>({
+      sendMessageResponse: {},
+    })
+    sendMessageMock = testable.sendMessageMock
+    service = testable.service
+  })
+
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  // FR: A04.FR.01
+  await it('should send SecurityEventNotification with type and timestamp', async () => {
+    const testTimestamp = new Date('2024-03-15T10:30:00.000Z')
+    const testType = 'FirmwareUpdated'
+
+    await service.requestSecurityEventNotification(station, testType, testTimestamp)
+
+    assert.strictEqual(sendMessageMock.mock.calls.length, 1)
+
+    const sentPayload = sendMessageMock.mock.calls[0]
+      .arguments[2] as OCPP20SecurityEventNotificationRequest
+    assert.strictEqual(sentPayload.type, testType)
+    assert.strictEqual(sentPayload.timestamp, testTimestamp)
+    assert.strictEqual(sentPayload.techInfo, undefined)
+
+    const commandName = sendMessageMock.mock.calls[0].arguments[3]
+    assert.strictEqual(commandName, OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION)
+  })
+
+  // FR: A04.FR.02
+  await it('should include techInfo when provided', async () => {
+    const testTimestamp = new Date('2024-03-15T11:00:00.000Z')
+    const testType = 'TamperDetectionActivated'
+    const testTechInfo = 'Enclosure opened at connector 1'
+
+    await service.requestSecurityEventNotification(station, testType, testTimestamp, testTechInfo)
+
+    assert.strictEqual(sendMessageMock.mock.calls.length, 1)
+
+    const sentPayload = sendMessageMock.mock.calls[0]
+      .arguments[2] as OCPP20SecurityEventNotificationRequest
+    assert.strictEqual(sentPayload.type, testType)
+    assert.strictEqual(sentPayload.timestamp, testTimestamp)
+    assert.strictEqual(sentPayload.techInfo, testTechInfo)
+  })
+
+  // FR: A04.FR.03
+  await it('should return empty response from CSMS', async () => {
+    const response = await service.requestSecurityEventNotification(
+      station,
+      'SettingSystemTime',
+      new Date('2024-03-15T12:00:00.000Z')
+    )
+
+    assert.notStrictEqual(response, undefined)
+    assert.strictEqual(typeof response, 'object')
+  })
+})
index 257bb904b5c7a09fb562f3ad7c26b53d256137a8..223315e73a56ed5bd3281286caa76b701295ba30 100644 (file)
 /**
  * @file Tests for OCPP20ResponseService TransactionEvent response handling
- * @description Unit tests for OCPP 2.0 TransactionEvent response processing (E01-E04)
- *
- * Covers:
- * - E01-E04 TransactionEventResponse handler branch coverage
- * - Empty response (no optional fields) — baseline
- * - totalCost logging branch
- * - chargingPriority logging branch
- * - idTokenInfo.Accepted logging branch
- * - idTokenInfo.Invalid logging branch
- * - updatedPersonalMessage logging branch
- * - All fields together
+ * @description Unit tests for OCPP 2.0.1 TransactionEvent response processing including
+ * idTokenInfo.status enforcement per spec D01/D05 — rejected statuses must trigger transaction stop
  */
 
 import assert from 'node:assert/strict'
 import { afterEach, beforeEach, describe, it, mock } from 'node:test'
 
-import type { MockChargingStation } from '../../ChargingStationTestUtils.js'
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+import type {
+  OCPP20TransactionEventRequest,
+  OCPP20TransactionEventResponse,
+} from '../../../../src/types/index.js'
+import type { UUIDv4 } from '../../../../src/types/UUID.js'
 
 import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js'
-import { OCPP20RequestCommand, OCPPVersion } from '../../../../src/types/index.js'
+import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
+import { OCPPVersion } from '../../../../src/types/index.js'
 import {
   OCPP20AuthorizationStatusEnumType,
-  type OCPP20MessageContentType,
   OCPP20MessageFormatEnumType,
-  type OCPP20TransactionEventResponse,
+  OCPP20TransactionEventEnumType,
+  OCPP20TriggerReasonEnumType,
 } from '../../../../src/types/ocpp/2.0/Transaction.js'
 import { Constants } from '../../../../src/utils/index.js'
-import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import {
+  setupConnectorWithTransaction,
+  standardCleanup,
+} from '../../../helpers/TestLifecycleHelpers.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
 import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
 
+/** UUID used as transactionId in all tests — must match connector.transactionId */
+const TEST_TRANSACTION_ID: UUIDv4 = '00000000-0000-0000-0000-000000000001'
+
+interface TestableOCPP20ResponseService {
+  handleResponseTransactionEvent: (
+    chargingStation: ChargingStation,
+    payload: OCPP20TransactionEventResponse,
+    requestPayload: OCPP20TransactionEventRequest
+  ) => void
+}
+
 /**
- * Create a mock station suitable for TransactionEvent response tests.
- * Uses ocppStrictCompliance: false to bypass AJV validation so the
- * handler logic can be tested in isolation.
- * @returns A mock station configured for TransactionEvent tests
+ * Builds a minimal OCPP20TransactionEventRequest for use as requestPayload in tests.
+ * @param transactionId - The transaction UUID to embed in transactionInfo
+ * @returns A minimal OCPP20TransactionEventRequest
  */
-function createTransactionEventStation (): MockChargingStation {
-  const { station } = createMockChargingStation({
-    baseName: TEST_CHARGING_STATION_BASE_NAME,
-    connectorsCount: 1,
-    heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
-    stationInfo: {
-      // Bypass AJV schema validation — tests focus on handler logic
-      ocppStrictCompliance: false,
-      ocppVersion: OCPPVersion.VERSION_201,
+function buildTransactionEventRequest (transactionId: UUIDv4): OCPP20TransactionEventRequest {
+  return {
+    eventType: OCPP20TransactionEventEnumType.Updated,
+    meterValue: [],
+    seqNo: 0,
+    timestamp: new Date(),
+    transactionInfo: {
+      transactionId,
     },
-    websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
-  })
-  return station as MockChargingStation
+    triggerReason: OCPP20TriggerReasonEnumType.Authorized,
+  }
+}
+
+/**
+ * Creates a testable wrapper around OCPP20ResponseService.
+ * @param service - The OCPP20ResponseService instance to wrap
+ * @returns A typed interface exposing private handler methods
+ */
+function createTestableResponseService (
+  service: OCPP20ResponseService
+): TestableOCPP20ResponseService {
+  const serviceImpl = service as unknown as TestableOCPP20ResponseService
+  return {
+    handleResponseTransactionEvent: serviceImpl.handleResponseTransactionEvent.bind(service),
+  }
 }
 
-await describe('E01-E04 - TransactionEventResponse handler', async () => {
-  let responseService: OCPP20ResponseService
-  let mockStation: MockChargingStation
+await describe('D01 - TransactionEvent Response', async () => {
+  let station: ChargingStation
+  let testable: TestableOCPP20ResponseService
 
   beforeEach(() => {
-    mock.timers.enable({ apis: ['setInterval', 'setTimeout'] })
-    responseService = new OCPP20ResponseService()
-    mockStation = createTransactionEventStation()
+    const { station: mockStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 1,
+      evseConfiguration: { evsesCount: 1 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      stationInfo: {
+        ocppStrictCompliance: false,
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    station = mockStation
+    // Set connector transactionId to the UUID string used in request payloads
+    setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+    // Override with UUID string so getConnectorIdByTransactionId can find it
+    const connector = station.getConnectorStatus(1)
+    if (connector != null) {
+      connector.transactionId = TEST_TRANSACTION_ID
+    }
+    const responseService = new OCPP20ResponseService()
+    testable = createTestableResponseService(responseService)
   })
 
   afterEach(() => {
     standardCleanup()
   })
 
-  /**
-   * Helper to dispatch a TransactionEventResponse through the public responseHandler.
-   * The station is in Accepted state by default (RegistrationStatusEnumType.ACCEPTED).
-   * @param payload - The TransactionEventResponse payload to dispatch
-   * @returns Resolves when the response handler completes
-   */
-  async function dispatch (payload: OCPP20TransactionEventResponse): Promise<void> {
-    await responseService.responseHandler(
-      mockStation,
-      OCPP20RequestCommand.TRANSACTION_EVENT,
-      payload as unknown as Parameters<typeof responseService.responseHandler>[2],
-      {} as Parameters<typeof responseService.responseHandler>[3]
+  await it('should not stop transaction when idTokenInfo status is Accepted', () => {
+    // Arrange
+    const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
+      Promise.resolve({ status: 'Accepted' })
     )
-  }
+    const payload: OCPP20TransactionEventResponse = {
+      idTokenInfo: {
+        status: OCPP20AuthorizationStatusEnumType.Accepted,
+      },
+    }
+    const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_ID)
 
-  await it('should handle empty TransactionEvent response without throwing', async () => {
-    const payload: OCPP20TransactionEventResponse = {}
-    await assert.doesNotReject(dispatch(payload))
+    // Act
+    testable.handleResponseTransactionEvent(station, payload, requestPayload)
+
+    // Assert
+    assert.strictEqual(mockStopTransaction.mock.calls.length, 0)
   })
 
-  await it('should handle totalCost field without throwing', async () => {
-    const payload: OCPP20TransactionEventResponse = { totalCost: 12.5 }
-    await assert.doesNotReject(dispatch(payload))
+  await it('should stop only the specific transaction when idTokenInfo status is Invalid', () => {
+    // Arrange
+    const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
+      Promise.resolve({ status: 'Accepted' })
+    )
+    const payload: OCPP20TransactionEventResponse = {
+      idTokenInfo: {
+        status: OCPP20AuthorizationStatusEnumType.Invalid,
+      },
+    }
+    const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_ID)
+
+    // Act
+    testable.handleResponseTransactionEvent(station, payload, requestPayload)
+
+    // Assert — only the specific connector (1) on EVSE (1) is stopped
+    assert.strictEqual(mockStopTransaction.mock.calls.length, 1)
+    assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[0], station)
+    assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[1], 1)
+    assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[2], 1)
   })
 
-  await it('should handle chargingPriority field without throwing', async () => {
-    const payload: OCPP20TransactionEventResponse = { chargingPriority: 1 }
-    await assert.doesNotReject(dispatch(payload))
+  await it('should stop only the specific transaction when idTokenInfo status is Blocked', () => {
+    // Arrange
+    const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
+      Promise.resolve({ status: 'Accepted' })
+    )
+    const payload: OCPP20TransactionEventResponse = {
+      idTokenInfo: {
+        status: OCPP20AuthorizationStatusEnumType.Blocked,
+      },
+    }
+    const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_ID)
+
+    // Act
+    testable.handleResponseTransactionEvent(station, payload, requestPayload)
+
+    // Assert
+    assert.strictEqual(mockStopTransaction.mock.calls.length, 1)
+    assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[0], station)
   })
 
-  await it('should handle idTokenInfo with Accepted status without throwing', async () => {
+  await it('should not stop transaction when only chargingPriority is present', () => {
+    // Arrange
+    const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
+      Promise.resolve({ status: 'Accepted' })
+    )
+    const payload: OCPP20TransactionEventResponse = {
+      chargingPriority: 5,
+    }
+    const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_ID)
+
+    // Act
+    testable.handleResponseTransactionEvent(station, payload, requestPayload)
+
+    // Assert
+    assert.strictEqual(mockStopTransaction.mock.calls.length, 0)
+  })
+
+  await it('should handle empty response without stopping transaction', () => {
+    // Arrange
+    const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
+      Promise.resolve({ status: 'Accepted' })
+    )
+    const payload: OCPP20TransactionEventResponse = {}
+    const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_ID)
+
+    // Act
+    testable.handleResponseTransactionEvent(station, payload, requestPayload)
+
+    // Assert
+    assert.strictEqual(mockStopTransaction.mock.calls.length, 0)
+  })
+
+  await it('should stop only the specific transaction when idTokenInfo status is Expired', () => {
+    // Arrange
+    const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
+      Promise.resolve({ status: 'Accepted' })
+    )
     const payload: OCPP20TransactionEventResponse = {
       idTokenInfo: {
-        status: OCPP20AuthorizationStatusEnumType.Accepted,
+        status: OCPP20AuthorizationStatusEnumType.Expired,
       },
     }
-    await assert.doesNotReject(dispatch(payload))
+    const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_ID)
+
+    // Act
+    testable.handleResponseTransactionEvent(station, payload, requestPayload)
+
+    // Assert
+    assert.strictEqual(mockStopTransaction.mock.calls.length, 1)
   })
 
-  await it('should handle idTokenInfo with Invalid status without throwing', async () => {
+  await it('should stop only the specific transaction when idTokenInfo status is NoCredit', () => {
+    // Arrange
+    const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
+      Promise.resolve({ status: 'Accepted' })
+    )
     const payload: OCPP20TransactionEventResponse = {
       idTokenInfo: {
-        status: OCPP20AuthorizationStatusEnumType.Invalid,
+        status: OCPP20AuthorizationStatusEnumType.NoCredit,
       },
     }
-    await assert.doesNotReject(dispatch(payload))
+    const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_ID)
+
+    // Act
+    testable.handleResponseTransactionEvent(station, payload, requestPayload)
+
+    // Assert
+    assert.strictEqual(mockStopTransaction.mock.calls.length, 1)
   })
 
-  await it('should handle updatedPersonalMessage field without throwing', async () => {
-    const message: OCPP20MessageContentType = {
-      content: 'Thank you for charging!',
-      format: OCPP20MessageFormatEnumType.UTF8,
+  await it('should not stop transaction when response has totalCost and updatedPersonalMessage', () => {
+    // Arrange
+    const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
+      Promise.resolve({ status: 'Accepted' })
+    )
+    const payload: OCPP20TransactionEventResponse = {
+      totalCost: 12.5,
+      updatedPersonalMessage: {
+        content: 'Charging session in progress',
+        format: OCPP20MessageFormatEnumType.UTF8,
+      },
     }
-    const payload: OCPP20TransactionEventResponse = { updatedPersonalMessage: message }
-    await assert.doesNotReject(dispatch(payload))
+    const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_ID)
+
+    // Act
+    testable.handleResponseTransactionEvent(station, payload, requestPayload)
+
+    // Assert
+    assert.strictEqual(mockStopTransaction.mock.calls.length, 0)
   })
 
-  await it('should handle all optional fields present simultaneously without throwing', async () => {
-    const message: OCPP20MessageContentType = {
-      content: '<b>Session complete</b>',
-      format: OCPP20MessageFormatEnumType.HTML,
+  await it('should stop only the targeted transaction on multi-EVSE station', () => {
+    // Set up a 2-EVSE station with active transactions on both EVSEs
+    const txn1: UUIDv4 = '00000000-0000-0000-0000-000000000010'
+    const txn2: UUIDv4 = '00000000-0000-0000-0000-000000000020'
+    const { station: multiStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 2,
+      evseConfiguration: { evsesCount: 2 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      stationInfo: {
+        ocppStrictCompliance: false,
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    setupConnectorWithTransaction(multiStation, 1, { transactionId: 10 })
+    const connector1 = multiStation.getConnectorStatus(1)
+    if (connector1 != null) {
+      connector1.transactionId = txn1
     }
+    setupConnectorWithTransaction(multiStation, 2, { transactionId: 20 })
+    const connector2 = multiStation.getConnectorStatus(2)
+    if (connector2 != null) {
+      connector2.transactionId = txn2
+    }
+
+    const mockStopTransaction = mock.method(OCPP20ServiceUtils, 'requestStopTransaction', () =>
+      Promise.resolve({ status: 'Accepted' })
+    )
     const payload: OCPP20TransactionEventResponse = {
-      chargingPriority: 2,
       idTokenInfo: {
-        chargingPriority: 3,
-        status: OCPP20AuthorizationStatusEnumType.Accepted,
+        status: OCPP20AuthorizationStatusEnumType.Invalid,
       },
-      totalCost: 9.99,
-      updatedPersonalMessage: message,
     }
-    await assert.doesNotReject(dispatch(payload))
+    const multiTestable = createTestableResponseService(new OCPP20ResponseService())
+
+    // Act — reject EVSE 1's transaction only
+    multiTestable.handleResponseTransactionEvent(
+      multiStation,
+      payload,
+      buildTransactionEventRequest(txn1)
+    )
+
+    // Assert — only 1 stop call targeting connector 1, EVSE 2 untouched
+    assert.strictEqual(mockStopTransaction.mock.calls.length, 1)
+    assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[0], multiStation)
+    assert.strictEqual(mockStopTransaction.mock.calls[0].arguments[1], 1)
   })
 })