]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
refactor(ocpp2): OCPP 2.0.1 audit fixes — spec compliance, type safety, test coverage...
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Tue, 3 Mar 2026 17:08:43 +0000 (18:08 +0100)
committerGitHub <noreply@github.com>
Tue, 3 Mar 2026 17:08:43 +0000 (18:08 +0100)
* feat(ocpp2): add TriggerMessage, UnlockConnector and TransactionEvent types

* fix(tests): resolve pre-existing LSP type errors in test infrastructure

* refactor(ocpp2): extract enforceMessageLimits utility to eliminate DRY violation

* refactor(ocpp2): dynamic isComponentValid and fix OCPP 1.6 key usage

* feat(ocpp2): handle TransactionEventResponse fields

* feat(ocpp2): implement TriggerMessage/UnlockConnector handlers and Reset hardening

- Implement TriggerMessage handler (F06): supports BootNotification,
  Heartbeat, and StatusNotification triggers with EVSE targeting
- Implement UnlockConnector handler (F05): full spec compliance with
  Unlocked, UnlockFailed, OngoingAuthorizedTransaction, UnknownConnector
  response statuses
- Add AllowReset variable check and firmware update blocking to Reset handler
- Add 4 missing schema validations for certificate commands
- Add TriggerMessage/UnlockConnector schema validator configs
- Document RFC 6960 DER encoding deviation in computeCertificateHash

* refactor(ocpp2): eliminate unsafe type casts in handlers and VariableManager

- Add private toHandler() helper in OCPP20IncomingRequestService that concentrates
  the single 'as unknown as IncomingRequestHandler' cast with documentation comment
- Replace 13 per-binding casts in constructor with this.toHandler() calls
- Remove 15 'as unknown as StandardParametersKey' casts from OCPP20VariableManager —
  string is already a member of ConfigurationKeyType, no cast needed
- Remove now-unused import of StandardParametersKey from OCPP20VariableManager

* feat(ocpp2): expose TriggerMessage and UnlockConnector handlers in testable interface

* test(ocpp2): add TriggerMessage handler tests (F06) — 14 tests

* test(ocpp2): add UnlockConnector handler tests (F05) — 9 tests

* test(ocpp2): add TransactionEventResponse handler tests (E01-E04) — 7 tests

* test(ocpp2): fix firmware blocking tests and add AllowReset variable guard tests (T22) — 25 tests

* fix(ocpp2): add AJV strict:false and schema validation unit tests (T21)

- Set strict:false in all three AJV constructors (IncomingRequest, Request,
  Response) to allow OCPP 2.0 schemas that use additionalItems without
  array items (a draft-07 pattern AJV 8 strict mode rejects at compile time)
- Replace integration-style schema tests (blocked by tsx path resolution)
  with direct AJV schema unit tests: load schemas from src/assets directly,
  compile with AJV, verify invalid payloads are rejected — 15 tests pass

* fix(ocpp2): correct registry key separator in isComponentValid (T5-bugfix)

The `#validComponentNames` set was built using split('/') but registry keys
use '::' as separator (e.g. 'AlignedDataCtrlr::Available'). This caused the
set to contain full keys like 'AlignedDataCtrlr::Available' instead of just
component names like 'AlignedDataCtrlr', making isComponentValid return false
for all components and causing UnknownComponent responses for all Set/GetVariables.

Also update the EVSE test to assert UnknownVariable (not UnknownComponent) since
EVSE is a valid component in the registry but AuthorizeRemoteStart is not one of
its variables.

* test(ocpp2): add enforceMessageLimits utility tests (T23) — 14 tests

* test(ocpp2): add integration tests for multi-command flows (T24) — 6 tests

* fix(ocpp2): add error diagnostics, additionalInfo fields, and async timeout guard (T25)

* fix(ocpp2): resolve tsc type errors in UUIDv4 cast, override modifier, and mock cast (F1)

* fix(ocpp2): add try/catch to TriggerMessage and UnlockConnector handlers, add integration test

- Add try/catch blocks to handleRequestTriggerMessage and
  handleRequestUnlockConnector following the handleRequestReset
  golden pattern with structured error responses
- Add 4th integration test: SetVariables on unknown component
  returns rejected, GetVariables confirms unknown component

* fix(ocpp2): address PR review findings — remove dead code, consolidate types, fix test mock

* fix(ocpp2): spec compliance — F06.FR.17 BootNotification guard, F05.FR.02 connector-level tx check

- TriggerMessage: reject BootNotification trigger when last boot was
  already Accepted per F06.FR.17 (returns Rejected + NotEnabled)
- UnlockConnector: check transaction on the specific target connector
  instead of all EVSE connectors per F05.FR.02
- Add 3 new tests covering both spec requirements

* fix(ocpp2): address code review — timer leak, diagnostic logging, TODO annotation

- Fix withTimeout timer leak: clear setTimeout when promise resolves first
- readMessageLimits: replace empty catch with logger.debug diagnostic
- handleResponseTransactionEvent: add TODO noting log-only is intentional,
  future work should act on idTokenInfo.status and chargingPriority

* style: fix jsdoc warnings in OCPP 2.0 handlers and tests

Add missing @returns tags and @param descriptions to satisfy
jsdoc/require-returns and jsdoc/require-param-description ESLint rules.
Replace empty JSDoc blocks with minimal valid documentation to satisfy
jsdoc/require-jsdoc rule. Achieves 0 lint warnings matching main branch
baseline.

* fix(types): reduce tsc errors to zero and fix all lint warnings

Eliminate all TypeScript compiler errors across src/ and test files by
fixing type mismatches, adding missing type annotations, and correcting
union type handling. Fix remaining ESLint issues including no-base-to-string
in ChargingProfile id extraction and jsdoc/tag-lines formatting.

* fix(ocpp2): fix AllowReset dead code, evseId===0 routing, and EVSE-scoped connector lookup

- AllowReset check now resolves via OCPP20VariableManager (runtime value) instead of static registry defaultValue
- Reset handler evseId===0 now routes to full station reset instead of EVSE-specific path
- TriggerMessage StatusNotification uses EVSE-scoped connector lookup instead of global getConnectorStatus

* ci: add typecheck step (tsc --noEmit --skipLibCheck) to CI pipeline

esbuild does not surface type errors at build time, so this catches
regressions before build+test. Runs on ubuntu/node-22 alongside lint.

* fix(ocpp2): remove DiagnosticsStatusNotification from MessageTriggerEnumType

This value is OCPP 1.6 only and not part of the OCPP 2.0.1 spec
(absent from TriggerMessageRequest.json schema).

* docs(readme): align OCPP 2.0.x section with spec and actual code

- Fix section letters to match OCPP 2.0.1 spec (A→B, B→C, C→G, etc.)
- Reorder sections to match spec block order (B,C,D,E,F,G,L,M,P)
- Add missing implemented commands: TriggerMessage, UnlockConnector,
  Get15118EVCertificate, GetCertificateStatus
- Move GetVariables/SetVariables from Monitoring to B. Provisioning
- Replace stale 'variables not implemented' note with actual support listing

* fix(ocpp2): add missing AJV response validators for 7 incoming request commands

CALLRESULT payloads were not validated against JSON schemas for
CertificateSigned, DeleteCertificate, GetInstalledCertificateIds,
GetVariables, InstallCertificate, Reset, and SetVariables.

All schema files already existed; only the validator map registration
was missing. Aligns OCPP 2.0 with OCPP 1.6 which validates all
incoming request responses (17/17).

* style(ocpp2): harmonize comment style with existing OCPP 1.6 sparse convention

Remove ~135 paraphrase/over-comments from OCPP 2.0 source files that
described what the next line obviously does. Keeps spec references
(C11.FR.04, F06.FR.17, OCPP 2.0.1 §2.10), TODO annotations, RFC 6960
deviation notes, eslint-disable directives, and terse comments matching
the existing OCPP 1.6 style. Test @file/@description headers preserved
per test style guide.

* [autofix.ci] apply automated fixes

* fix(ocpp2): correct spec references and harmonize format

- Fix wrong FR code: F03.FR.09 (triggerReason) → F03.FR.04 (meter
  values in TransactionEvent Ended)
- Harmonize FR comment format to 'X00.FR.00: description' style
- Harmonize section references to § symbol (was mixed Section/§)
- Harmonize RFC references to 'RFC 6960 §4.1.1' (was missing §)

* fix(ocpp): remove redundant strict:false from AJV instances

keywords: ['javaType'] already declares the custom keyword, making
strict:false unnecessary. Removing it enables AJV strict mode (default
since v7), which will flag any future non-standard schema keywords
instead of silently ignoring them.

* fix(types): make MeterValuesRequest/Response version-agnostic union types

Remove versioned OCPP16MeterValue import and casts from ChargingStation.ts,
following the established pattern of using union types outside the OCPP stack.

* fix(types): remove versioned type imports from Helpers.ts

Add ChargingSchedule union type, replace satisfies with version-agnostic
BootNotificationRequest, and rename getOCPP16ChargingSchedule to
getSingleChargingSchedule with union return type.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
63 files changed:
.github/workflows/ci.yml
README.md
package.json
scripts/runtime.d.ts [new file with mode: 0644]
src/charging-station/ChargingStation.ts
src/charging-station/Helpers.ts
src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts
src/charging-station/ocpp/1.6/OCPP16RequestService.ts
src/charging-station/ocpp/1.6/OCPP16ResponseService.ts
src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts
src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts
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/OCPP20VariableManager.ts
src/charging-station/ocpp/2.0/__testable__/OCPP20RequestServiceTestable.ts
src/charging-station/ocpp/2.0/__testable__/index.ts
src/charging-station/ocpp/OCPPServiceUtils.ts
src/performance/storage/MikroOrmStorage.ts
src/types/index.ts
src/types/ocpp/1.6/Requests.ts
src/types/ocpp/1.6/Responses.ts
src/types/ocpp/2.0/Common.ts
src/types/ocpp/2.0/Requests.ts
src/types/ocpp/2.0/Responses.ts
src/types/ocpp/2.0/Transaction.ts
src/types/ocpp/2.0/index.ts
src/types/ocpp/ChargingProfile.ts
src/types/ocpp/Common.ts
src/types/ocpp/Requests.ts
src/types/ocpp/Responses.ts
tests/charging-station/ChargingStation-Connectors.test.ts
tests/charging-station/ChargingStation-Lifecycle.test.ts
tests/charging-station/ChargingStation-Resilience.test.ts
tests/charging-station/ChargingStation-Transactions.test.ts
tests/charging-station/ChargingStation.test.ts
tests/charging-station/Helpers.test.ts
tests/charging-station/helpers/StationHelpers.ts
tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-DeleteCertificate.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetVariables.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-InstallCertificate.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RemoteStartAuth.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-Reset.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UnlockConnector.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20Integration-Certificate.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20Integration.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20RequestService-ISO15118.test.ts
tests/charging-station/ocpp/2.0/OCPP20RequestService-NotifyReport.test.ts
tests/charging-station/ocpp/2.0/OCPP20RequestService-SignCertificate.test.ts
tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20SchemaValidation.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts
tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-enforceMessageLimits.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts
tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts
tests/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.test.ts
tests/charging-station/ui-server/UIWebSocketServer.test.ts

index d6c4290f9c20a894f65ff9305bfb5677ba94aa75..b1de4a1e40949a20f2492e868f4e740508467040 100644 (file)
@@ -80,6 +80,9 @@ jobs:
       - name: pnpm lint
         if: ${{ matrix.os == 'ubuntu-latest' && matrix.node == '22.x' }}
         run: pnpm lint
+      - name: pnpm typecheck
+        if: ${{ matrix.os == 'ubuntu-latest' && matrix.node == '22.x' }}
+        run: pnpm typecheck
       - name: pnpm build
         run: pnpm build
       - name: pnpm test
index 5810dfecb284846ad85b0624220820bb976e2eb9..578121e076ef22275536a7b84a63243ff41f8136 100644 (file)
--- a/README.md
+++ b/README.md
@@ -494,20 +494,22 @@ make SUBMODULES_INIT=true
 
 > **Note**: OCPP 2.0.x implementation is **partial** and under active development.
 
-#### A. Provisioning
+#### B. Provisioning
 
 - :white_check_mark: BootNotification
 - :white_check_mark: GetBaseReport
+- :white_check_mark: GetVariables
 - :white_check_mark: NotifyReport
+- :white_check_mark: SetVariables
 
-#### B. Authorization
+#### C. Authorization
 
 - :white_check_mark: ClearCache
 
-#### C. Availability
+#### D. LocalAuthorizationListManagement
 
-- :white_check_mark: Heartbeat
-- :white_check_mark: StatusNotification
+- :x: GetLocalListVersion
+- :x: SendLocalList
 
 #### E. Transactions
 
@@ -518,21 +520,25 @@ make SUBMODULES_INIT=true
 #### F. RemoteControl
 
 - :white_check_mark: Reset
+- :white_check_mark: TriggerMessage
+- :white_check_mark: UnlockConnector
 
-#### G. Monitoring
+#### G. Availability
 
-- :white_check_mark: GetVariables
-- :white_check_mark: SetVariables
+- :white_check_mark: Heartbeat
+- :white_check_mark: StatusNotification
 
-#### H. FirmwareManagement
+#### L. FirmwareManagement
 
 - :x: UpdateFirmware
 - :x: FirmwareStatusNotification
 
-#### I. ISO15118CertificateManagement
+#### M. ISO 15118 CertificateManagement
 
 - :white_check_mark: CertificateSigned
 - :white_check_mark: DeleteCertificate
+- :white_check_mark: Get15118EVCertificate
+- :white_check_mark: GetCertificateStatus
 - :white_check_mark: GetInstalledCertificateIds
 - :white_check_mark: InstallCertificate
 - :white_check_mark: SignCertificate
@@ -542,12 +548,7 @@ 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.
 
-#### J. LocalAuthorizationListManagement
-
-- :x: GetLocalListVersion
-- :x: SendLocalList
-
-#### K. DataTransfer
+#### P. DataTransfer
 
 - :x: DataTransfer
 
@@ -609,7 +610,8 @@ All kind of OCPP parameters are supported in charging station configuration or c
 
 ### Version 2.0.x
 
-> **Note**: OCPP 2.0.x variables management is not yet implemented.
+- :white_check_mark: GetVariables
+- :white_check_mark: SetVariables
 
 ## UI Protocol
 
index dec550dc9b3b217668d2181b93bf9fdbc909fe90..aa3d4b8889fa1e8a002f00ded632858c03419867 100644 (file)
@@ -64,6 +64,7 @@
     "build:entities": "tsc -p tsconfig-mikro-orm.json",
     "clean:dist": "pnpm exec rimraf dist",
     "clean:node_modules": "pnpm exec rimraf node_modules",
+    "typecheck": "tsc --noEmit --skipLibCheck",
     "lint": "cross-env TIMING=1 eslint --cache src tests scripts ./*.js ./*.ts",
     "lint:fix": "cross-env TIMING=1 eslint --cache --fix src tests scripts ./*.js ./*.ts",
     "format": "prettier --cache --write .; eslint --cache --fix src tests scripts ./*.js ./*.ts",
diff --git a/scripts/runtime.d.ts b/scripts/runtime.d.ts
new file mode 100644 (file)
index 0000000..518d3b1
--- /dev/null
@@ -0,0 +1,8 @@
+export declare const JSRuntime: {
+  browser: 'browser'
+  bun: 'bun'
+  deno: 'deno'
+  node: 'node'
+  workerd: 'workerd'
+}
+export declare const runtime: string
index 008033c983d6205510a6e9f1c2a625b339f30918..db4469a3c8d8a35489ef7969007c5d32c5193615 100644 (file)
@@ -1050,13 +1050,8 @@ export class ChargingStation extends EventEmitter {
     }
     if (interval > 0) {
       connectorStatus.transactionSetInterval = setInterval(() => {
-        const meterValue = buildMeterValue(
-          this,
-          connectorId,
-          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-          connectorStatus.transactionId!,
-          interval
-        )
+        const transactionId = convertToInt(connectorStatus.transactionId)
+        const meterValue = buildMeterValue(this, connectorId, transactionId, interval)
         this.ocppRequestService
           .requestHandler<MeterValuesRequest, MeterValuesResponse>(
             this,
@@ -1064,8 +1059,8 @@ export class ChargingStation extends EventEmitter {
             {
               connectorId,
               meterValue: [meterValue],
-              transactionId: connectorStatus.transactionId,
-            }
+              transactionId,
+            } as MeterValuesRequest
           )
           .catch((error: unknown) => {
             logger.error(
@@ -1175,7 +1170,8 @@ export class ChargingStation extends EventEmitter {
     connectorId: number,
     reason?: StopTransactionReason
   ): Promise<StopTransactionResponse> {
-    const transactionId = this.getConnectorStatus(connectorId)?.transactionId
+    const rawTransactionId = this.getConnectorStatus(connectorId)?.transactionId
+    const transactionId = rawTransactionId != null ? convertToInt(rawTransactionId) : undefined
     if (
       this.stationInfo?.beginEndMeterValues === true &&
       this.stationInfo.ocppStrictCompliance === true &&
@@ -1184,7 +1180,7 @@ export class ChargingStation extends EventEmitter {
       const transactionEndMeterValue = buildTransactionEndMeterValue(
         this,
         connectorId,
-        this.getEnergyActiveImportRegisterByTransactionId(transactionId)
+        this.getEnergyActiveImportRegisterByTransactionId(rawTransactionId)
       )
       await this.ocppRequestService.requestHandler<MeterValuesRequest, MeterValuesResponse>(
         this,
@@ -1193,14 +1189,14 @@ export class ChargingStation extends EventEmitter {
           connectorId,
           meterValue: [transactionEndMeterValue],
           transactionId,
-        }
+        } as MeterValuesRequest
       )
     }
     return await this.ocppRequestService.requestHandler<
       Partial<StopTransactionRequest>,
       StopTransactionResponse
     >(this, RequestCommand.STOP_TRANSACTION, {
-      meterStop: this.getEnergyActiveImportRegisterByTransactionId(transactionId, true),
+      meterStop: this.getEnergyActiveImportRegisterByTransactionId(rawTransactionId, true),
       transactionId,
       ...(reason != null && { reason }),
     })
index 35a596b465678b5d6e5dad06af2635cda2bf7ab8..231ddda53ab015456fdedbcea3933c12d984ba61 100644 (file)
@@ -34,6 +34,7 @@ import {
   ChargingProfileKindType,
   ChargingProfilePurposeType,
   ChargingRateUnitType,
+  type ChargingSchedule,
   type ChargingSchedulePeriod,
   type ChargingStationConfiguration,
   type ChargingStationInfo,
@@ -45,8 +46,6 @@ import {
   ConnectorStatusEnum,
   CurrentType,
   type EvseTemplate,
-  type OCPP16BootNotificationRequest,
-  type OCPP20BootNotificationRequest,
   OCPPVersion,
   RecurrencyKindType,
   type Reservation,
@@ -547,10 +546,10 @@ export const prepareConnectorStatus = (connectorStatus: ConnectorStatus): Connec
           chargingProfile.chargingProfilePurpose !== ChargingProfilePurposeType.TX_PROFILE
       )
       .map(chargingProfile => {
-        chargingProfile.chargingSchedule.startSchedule = convertToDate(
-          // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
-          chargingProfile.chargingSchedule.startSchedule
-        )
+        const chargingSchedule = getSingleChargingSchedule(chargingProfile)
+        if (chargingSchedule != null) {
+          chargingSchedule.startSchedule = convertToDate(chargingSchedule.startSchedule)
+        }
         chargingProfile.validFrom = convertToDate(chargingProfile.validFrom)
         chargingProfile.validTo = convertToDate(chargingProfile.validTo)
         return chargingProfile
@@ -586,7 +585,7 @@ export const createBootNotificationRequest = (
         ...(stationInfo.meterType != null && {
           meterType: stationInfo.meterType,
         }),
-      } satisfies OCPP16BootNotificationRequest
+      } satisfies BootNotificationRequest
     case OCPPVersion.VERSION_20:
     case OCPPVersion.VERSION_201:
       return {
@@ -607,7 +606,7 @@ export const createBootNotificationRequest = (
           }),
         },
         reason: bootReason,
-      } satisfies OCPP20BootNotificationRequest
+      } satisfies BootNotificationRequest
   }
 }
 
@@ -751,8 +750,7 @@ export const getChargingStationChargingProfilesLimit = (
         chargingStation.stationInfo!.maximumPower!
       if (limit > chargingStationMaximumPower) {
         logger.error(
-          // eslint-disable-next-line @typescript-eslint/no-base-to-string
-          `${chargingStation.logPrefix()} ${moduleName}.getChargingStationChargingProfilesLimit: Charging profile id ${chargingProfilesLimit.chargingProfile.chargingProfileId.toString()} limit ${limit.toString()} is greater than charging station maximum ${chargingStationMaximumPower.toString()}: %j`,
+          `${chargingStation.logPrefix()} ${moduleName}.getChargingStationChargingProfilesLimit: Charging profile id ${getChargingProfileId(chargingProfilesLimit.chargingProfile)} limit ${limit.toString()} is greater than charging station maximum ${chargingStationMaximumPower.toString()}: %j`,
           chargingProfilesLimit
         )
         return chargingStationMaximumPower
@@ -817,8 +815,7 @@ export const getConnectorChargingProfilesLimit = (
         chargingStation.stationInfo!.maximumPower! / chargingStation.powerDivider!
       if (limit > connectorMaximumPower) {
         logger.error(
-          // eslint-disable-next-line @typescript-eslint/no-base-to-string
-          `${chargingStation.logPrefix()} ${moduleName}.getConnectorChargingProfilesLimit: Charging profile id ${chargingProfilesLimit.chargingProfile.chargingProfileId.toString()} limit ${limit.toString()} is greater than connector ${connectorId.toString()} maximum ${connectorMaximumPower.toString()}: %j`,
+          `${chargingStation.logPrefix()} ${moduleName}.getConnectorChargingProfilesLimit: Charging profile id ${getChargingProfileId(chargingProfilesLimit.chargingProfile)} limit ${limit.toString()} is greater than connector ${connectorId.toString()} maximum ${connectorMaximumPower.toString()}: %j`,
           chargingProfilesLimit
         )
         return connectorMaximumPower
@@ -835,9 +832,17 @@ const buildChargingProfilesLimit = (
   // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
   const errorMsg = `Unknown ${chargingStation.stationInfo?.currentOutType} currentOutType in charging station information, cannot build charging profiles limit`
   const { chargingProfile, limit } = chargingProfilesLimit
+  const chargingSchedule = getSingleChargingSchedule(
+    chargingProfile,
+    chargingStation.logPrefix(),
+    'buildChargingProfilesLimit'
+  )
+  if (chargingSchedule == null) {
+    return limit
+  }
   switch (chargingStation.stationInfo?.currentOutType) {
     case CurrentType.AC:
-      return chargingProfile.chargingSchedule.chargingRateUnit === ChargingRateUnitType.WATT
+      return chargingSchedule.chargingRateUnit === ChargingRateUnitType.WATT
         ? limit
         : ACElectricUtils.powerTotal(
           chargingStation.getNumberOfPhases(),
@@ -845,7 +850,7 @@ const buildChargingProfilesLimit = (
           limit
         )
     case CurrentType.DC:
-      return chargingProfile.chargingSchedule.chargingRateUnit === ChargingRateUnitType.WATT
+      return chargingSchedule.chargingRateUnit === ChargingRateUnitType.WATT
         ? limit
         : DCElectricUtils.power(chargingStation.getVoltageOut(), limit)
     default:
@@ -1003,6 +1008,26 @@ interface ChargingProfilesLimit {
   limit: number
 }
 
+const getChargingProfileId = (chargingProfile: ChargingProfile): string => {
+  const id = chargingProfile.chargingProfileId ?? chargingProfile.id
+  return typeof id === 'number' ? id.toString() : 'unknown'
+}
+
+const getSingleChargingSchedule = (
+  chargingProfile: ChargingProfile,
+  logPrefix?: string,
+  methodName?: string
+): ChargingSchedule | undefined => {
+  if (!Array.isArray(chargingProfile.chargingSchedule)) {
+    return chargingProfile.chargingSchedule
+  }
+  if (logPrefix != null && methodName != null) {
+    logger.debug(
+      `${logPrefix} ${moduleName}.${methodName}: Charging profile id ${getChargingProfileId(chargingProfile)} has an OCPP 2.0 chargingSchedule array and is skipped`
+    )
+  }
+}
+
 /**
  * Get the charging profiles limit for a connector
  * Charging profiles shall already be sorted by priorities
@@ -1021,30 +1046,35 @@ const getChargingProfilesLimit = (
   const connectorStatus = chargingStation.getConnectorStatus(connectorId)
   let previousActiveChargingProfile: ChargingProfile | undefined
   for (const chargingProfile of chargingProfiles) {
-    const chargingSchedule = chargingProfile.chargingSchedule
+    const chargingProfileId = getChargingProfileId(chargingProfile)
+    const chargingSchedule = getSingleChargingSchedule(
+      chargingProfile,
+      chargingStation.logPrefix(),
+      'getChargingProfilesLimit'
+    )
+    if (chargingSchedule == null) {
+      continue
+    }
     if (chargingSchedule.startSchedule == null) {
       logger.debug(
-        // eslint-disable-next-line @typescript-eslint/no-base-to-string
-        `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfile.chargingProfileId.toString()} has no startSchedule defined. Trying to set it to the connector current transaction start date`
+        `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} has no startSchedule defined. Trying to set it to the connector current transaction start date`
       )
       // OCPP specifies that if startSchedule is not defined, it should be relative to start of the connector transaction
       chargingSchedule.startSchedule = connectorStatus?.transactionStart
     }
     if (!isDate(chargingSchedule.startSchedule)) {
       logger.warn(
-        // eslint-disable-next-line @typescript-eslint/no-base-to-string
-        `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfile.chargingProfileId.toString()} startSchedule property is not a Date instance. Trying to convert it to a Date instance`
+        `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} startSchedule property is not a Date instance. Trying to convert it to a Date instance`
       )
-      // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-non-null-assertion
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
       chargingSchedule.startSchedule = convertToDate(chargingSchedule.startSchedule)!
     }
     if (chargingSchedule.duration == null) {
       logger.debug(
-        // eslint-disable-next-line @typescript-eslint/no-base-to-string
-        `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfile.chargingProfileId.toString()} has no duration defined and will be set to the maximum time allowed`
+        `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} has no duration defined and will be set to the maximum time allowed`
       )
       // OCPP specifies that if duration is not defined, it should be infinite
-      // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+
       chargingSchedule.duration = differenceInSeconds(maxTime, chargingSchedule.startSchedule)
     }
     if (
@@ -1063,9 +1093,8 @@ const getChargingProfilesLimit = (
     // Check if the charging profile is active
     if (
       isWithinInterval(currentDate, {
-        // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
         end: addSeconds(chargingSchedule.startSchedule, chargingSchedule.duration),
-        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+
         start: chargingSchedule.startSchedule,
       })
     ) {
@@ -1076,33 +1105,30 @@ const getChargingProfilesLimit = (
         ): number => a.startPeriod - b.startPeriod
         if (
           !isArraySorted<ChargingSchedulePeriod>(
-            // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
             chargingSchedule.chargingSchedulePeriod,
             chargingSchedulePeriodCompareFn
           )
         ) {
           logger.warn(
-            // eslint-disable-next-line @typescript-eslint/no-base-to-string
-            `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfile.chargingProfileId.toString()} schedule periods are not sorted by start period`
+            `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} schedule periods are not sorted by start period`
           )
-          // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
+
           chargingSchedule.chargingSchedulePeriod.sort(chargingSchedulePeriodCompareFn)
         }
         // Check if the first schedule period startPeriod property is equal to 0
-        // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+
         if (chargingSchedule.chargingSchedulePeriod[0].startPeriod !== 0) {
           logger.error(
-            // eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
-            `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfile.chargingProfileId.toString()} first schedule period start period ${chargingSchedule.chargingSchedulePeriod[0].startPeriod.toString()} is not equal to 0`
+            `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} first schedule period start period ${chargingSchedule.chargingSchedulePeriod[0].startPeriod.toString()} is not equal to 0`
           )
           continue
         }
         // Handle only one schedule period
-        // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+
         if (chargingSchedule.chargingSchedulePeriod.length === 1) {
           const chargingProfilesLimit: ChargingProfilesLimit = {
             chargingProfile,
-            // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
+
             limit: chargingSchedule.chargingSchedulePeriod[0].limit,
           }
           logger.debug(debugLogMsg, chargingProfilesLimit)
@@ -1113,12 +1139,10 @@ const getChargingProfilesLimit = (
         for (const [
           index,
           chargingSchedulePeriod,
-          // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
         ] of chargingSchedule.chargingSchedulePeriod.entries()) {
           // Find the right schedule period
           if (
             isAfter(
-              // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
               addSeconds(chargingSchedule.startSchedule, chargingSchedulePeriod.startPeriod),
               currentDate
             )
@@ -1126,7 +1150,7 @@ const getChargingProfilesLimit = (
             // Found the schedule period: previous is the correct one
             const chargingProfilesLimit: ChargingProfilesLimit = {
               chargingProfile: previousActiveChargingProfile ?? chargingProfile,
-              // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
+
               limit: previousChargingSchedulePeriod?.limit ?? chargingSchedulePeriod.limit,
             }
             logger.debug(debugLogMsg, chargingProfilesLimit)
@@ -1134,30 +1158,28 @@ const getChargingProfilesLimit = (
           }
           // Handle the last schedule period within the charging profile duration
           if (
-            // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
             index === chargingSchedule.chargingSchedulePeriod.length - 1 ||
-            // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
             (index < chargingSchedule.chargingSchedulePeriod.length - 1 &&
               differenceInSeconds(
                 addSeconds(
                   chargingSchedule.startSchedule,
-                  // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-plus-operands
+
                   chargingSchedule.chargingSchedulePeriod[index + 1].startPeriod
                 ),
-                // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+
                 chargingSchedule.startSchedule
               ) > chargingSchedule.duration)
           ) {
             const chargingProfilesLimit: ChargingProfilesLimit = {
               chargingProfile,
-              // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
+
               limit: chargingSchedulePeriod.limit,
             }
             logger.debug(debugLogMsg, chargingProfilesLimit)
             return chargingProfilesLimit
           }
           // Keep a reference to previous charging schedule period
-          // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+
           previousChargingSchedulePeriod = chargingSchedulePeriod
         }
       }
@@ -1173,6 +1195,15 @@ export const prepareChargingProfileKind = (
   currentDate: Date | number | string,
   logPrefix: string
 ): boolean => {
+  const chargingProfileId = getChargingProfileId(chargingProfile)
+  const chargingSchedule = getSingleChargingSchedule(
+    chargingProfile,
+    logPrefix,
+    'prepareChargingProfileKind'
+  )
+  if (chargingSchedule == null) {
+    return false
+  }
   switch (chargingProfile.chargingProfileKind) {
     case ChargingProfileKindType.RECURRING:
       if (!canProceedRecurringChargingProfile(chargingProfile, logPrefix)) {
@@ -1181,14 +1212,14 @@ export const prepareChargingProfileKind = (
       prepareRecurringChargingProfile(chargingProfile, currentDate, logPrefix)
       break
     case ChargingProfileKindType.RELATIVE:
-      if (chargingProfile.chargingSchedule.startSchedule != null) {
+      if (chargingSchedule.startSchedule != null) {
         logger.warn(
-          `${logPrefix} ${moduleName}.prepareChargingProfileKind: Relative charging profile id ${chargingProfile.chargingProfileId.toString()} has a startSchedule property defined. It will be ignored or used if the connector has a transaction started`
+          `${logPrefix} ${moduleName}.prepareChargingProfileKind: Relative charging profile id ${chargingProfileId} has a startSchedule property defined. It will be ignored or used if the connector has a transaction started`
         )
-        delete chargingProfile.chargingSchedule.startSchedule
+        delete chargingSchedule.startSchedule
       }
       if (connectorStatus?.transactionStarted === true) {
-        chargingProfile.chargingSchedule.startSchedule = connectorStatus.transactionStart
+        chargingSchedule.startSchedule = connectorStatus.transactionStart
       }
       // FIXME: handle relative charging profile duration
       break
@@ -1201,40 +1232,42 @@ export const canProceedChargingProfile = (
   currentDate: Date | number | string,
   logPrefix: string
 ): boolean => {
+  const chargingProfileId = getChargingProfileId(chargingProfile)
+  const chargingSchedule = getSingleChargingSchedule(
+    chargingProfile,
+    logPrefix,
+    'canProceedChargingProfile'
+  )
+  if (chargingSchedule == null) {
+    return false
+  }
   if (
     (isValidDate(chargingProfile.validFrom) && isBefore(currentDate, chargingProfile.validFrom)) ||
     (isValidDate(chargingProfile.validTo) && isAfter(currentDate, chargingProfile.validTo))
   ) {
     logger.debug(
-      // eslint-disable-next-line @typescript-eslint/no-base-to-string
-      `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfile.chargingProfileId.toString()} is not valid for the current date ${
+      `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfileId} is not valid for the current date ${
         isDate(currentDate) ? currentDate.toISOString() : currentDate.toString()
       }`
     )
     return false
   }
-  if (
-    chargingProfile.chargingSchedule.startSchedule == null ||
-    chargingProfile.chargingSchedule.duration == null
-  ) {
+  if (chargingSchedule.startSchedule == null || chargingSchedule.duration == null) {
     logger.error(
-      // eslint-disable-next-line @typescript-eslint/no-base-to-string
-      `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfile.chargingProfileId.toString()} has no startSchedule or duration defined`
+      `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfileId} has no startSchedule or duration defined`
     )
     return false
   }
-  // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
-  if (!isValidDate(chargingProfile.chargingSchedule.startSchedule)) {
+
+  if (!isValidDate(chargingSchedule.startSchedule)) {
     logger.error(
-      // eslint-disable-next-line @typescript-eslint/no-base-to-string
-      `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfile.chargingProfileId.toString()} has an invalid startSchedule date defined`
+      `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfileId} has an invalid startSchedule date defined`
     )
     return false
   }
-  if (!Number.isSafeInteger(chargingProfile.chargingSchedule.duration)) {
+  if (!Number.isSafeInteger(chargingSchedule.duration)) {
     logger.error(
-      // eslint-disable-next-line @typescript-eslint/no-base-to-string
-      `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfile.chargingProfileId.toString()} has non integer duration defined`
+      `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfileId} has non integer duration defined`
     )
     return false
   }
@@ -1245,21 +1278,30 @@ const canProceedRecurringChargingProfile = (
   chargingProfile: ChargingProfile,
   logPrefix: string
 ): boolean => {
+  const chargingProfileId = getChargingProfileId(chargingProfile)
+  const chargingSchedule = getSingleChargingSchedule(
+    chargingProfile,
+    logPrefix,
+    'canProceedRecurringChargingProfile'
+  )
+  if (chargingSchedule == null) {
+    return false
+  }
   if (
     chargingProfile.chargingProfileKind === ChargingProfileKindType.RECURRING &&
     chargingProfile.recurrencyKind == null
   ) {
     logger.error(
-      `${logPrefix} ${moduleName}.canProceedRecurringChargingProfile: Recurring charging profile id ${chargingProfile.chargingProfileId.toString()} has no recurrencyKind defined`
+      `${logPrefix} ${moduleName}.canProceedRecurringChargingProfile: Recurring charging profile id ${chargingProfileId} has no recurrencyKind defined`
     )
     return false
   }
   if (
     chargingProfile.chargingProfileKind === ChargingProfileKindType.RECURRING &&
-    chargingProfile.chargingSchedule.startSchedule == null
+    chargingSchedule.startSchedule == null
   ) {
     logger.error(
-      `${logPrefix} ${moduleName}.canProceedRecurringChargingProfile: Recurring charging profile id ${chargingProfile.chargingProfileId.toString()} has no startSchedule defined`
+      `${logPrefix} ${moduleName}.canProceedRecurringChargingProfile: Recurring charging profile id ${chargingProfileId} has no startSchedule defined`
     )
     return false
   }
@@ -1278,15 +1320,23 @@ const prepareRecurringChargingProfile = (
   currentDate: Date | number | string,
   logPrefix: string
 ): boolean => {
-  const chargingSchedule = chargingProfile.chargingSchedule
+  const chargingProfileId = getChargingProfileId(chargingProfile)
+  const chargingSchedule = getSingleChargingSchedule(
+    chargingProfile,
+    logPrefix,
+    'prepareRecurringChargingProfile'
+  )
+  if (chargingSchedule == null) {
+    return false
+  }
   let recurringIntervalTranslated = false
   let recurringInterval: Interval | undefined
   switch (chargingProfile.recurrencyKind) {
     case RecurrencyKindType.DAILY:
       recurringInterval = {
-        // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion, @typescript-eslint/no-non-null-assertion
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
         end: addDays(chargingSchedule.startSchedule!, 1),
-        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-non-null-assertion
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
         start: chargingSchedule.startSchedule!,
       }
       checkRecurringChargingProfileDuration(chargingProfile, recurringInterval, logPrefix)
@@ -1294,14 +1344,13 @@ const prepareRecurringChargingProfile = (
         !isWithinInterval(currentDate, recurringInterval) &&
         isBefore(recurringInterval.end, currentDate)
       ) {
-        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
         chargingSchedule.startSchedule = addDays(
           recurringInterval.start,
           differenceInDays(currentDate, recurringInterval.start)
         )
         recurringInterval = {
           end: addDays(chargingSchedule.startSchedule, 1),
-          // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+
           start: chargingSchedule.startSchedule,
         }
         recurringIntervalTranslated = true
@@ -1309,9 +1358,9 @@ const prepareRecurringChargingProfile = (
       break
     case RecurrencyKindType.WEEKLY:
       recurringInterval = {
-        // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion, @typescript-eslint/no-non-null-assertion
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
         end: addWeeks(chargingSchedule.startSchedule!, 1),
-        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-non-null-assertion
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
         start: chargingSchedule.startSchedule!,
       }
       checkRecurringChargingProfileDuration(chargingProfile, recurringInterval, logPrefix)
@@ -1319,14 +1368,13 @@ const prepareRecurringChargingProfile = (
         !isWithinInterval(currentDate, recurringInterval) &&
         isBefore(recurringInterval.end, currentDate)
       ) {
-        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
         chargingSchedule.startSchedule = addWeeks(
           recurringInterval.start,
           differenceInWeeks(currentDate, recurringInterval.start)
         )
         recurringInterval = {
           end: addWeeks(chargingSchedule.startSchedule, 1),
-          // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+
           start: chargingSchedule.startSchedule,
         }
         recurringIntervalTranslated = true
@@ -1334,8 +1382,8 @@ const prepareRecurringChargingProfile = (
       break
     default:
       logger.error(
-        // eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions
-        `${logPrefix} ${moduleName}.prepareRecurringChargingProfile: Recurring ${chargingProfile.recurrencyKind} charging profile id ${chargingProfile.chargingProfileId.toString()} is not supported`
+        // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+        `${logPrefix} ${moduleName}.prepareRecurringChargingProfile: Recurring ${chargingProfile.recurrencyKind} charging profile id ${chargingProfileId} is not supported`
       )
   }
   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -1344,8 +1392,7 @@ const prepareRecurringChargingProfile = (
       `${logPrefix} ${moduleName}.prepareRecurringChargingProfile: Recurring ${
         // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
         chargingProfile.recurrencyKind
-        // eslint-disable-next-line @typescript-eslint/no-base-to-string
-      } charging profile id ${chargingProfile.chargingProfileId.toString()} recurrency time interval [${toDate(
+      } charging profile id ${chargingProfileId} recurrency time interval [${toDate(
         recurringInterval?.start as Date
       ).toISOString()}, ${toDate(
         recurringInterval?.end as Date
@@ -1362,30 +1409,35 @@ const checkRecurringChargingProfileDuration = (
   interval: Interval,
   logPrefix: string
 ): void => {
-  if (chargingProfile.chargingSchedule.duration == null) {
+  const chargingProfileId = getChargingProfileId(chargingProfile)
+  const chargingSchedule = getSingleChargingSchedule(
+    chargingProfile,
+    logPrefix,
+    'checkRecurringChargingProfileDuration'
+  )
+  if (chargingSchedule == null) {
+    return
+  }
+  if (chargingSchedule.duration == null) {
     logger.warn(
       `${logPrefix} ${moduleName}.checkRecurringChargingProfileDuration: Recurring ${
         chargingProfile.chargingProfileKind
-        // eslint-disable-next-line @typescript-eslint/no-base-to-string
-      } charging profile id ${chargingProfile.chargingProfileId.toString()} duration is not defined, set it to the recurrency time interval duration ${differenceInSeconds(
+      } charging profile id ${chargingProfileId} duration is not defined, set it to the recurrency time interval duration ${differenceInSeconds(
         interval.end,
         interval.start
       ).toString()}`
     )
-    chargingProfile.chargingSchedule.duration = differenceInSeconds(interval.end, interval.start)
-  } else if (
-    chargingProfile.chargingSchedule.duration > differenceInSeconds(interval.end, interval.start)
-  ) {
+    chargingSchedule.duration = differenceInSeconds(interval.end, interval.start)
+  } else if (chargingSchedule.duration > differenceInSeconds(interval.end, interval.start)) {
     logger.warn(
       `${logPrefix} ${moduleName}.checkRecurringChargingProfileDuration: Recurring ${
         chargingProfile.chargingProfileKind
-        // eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
-      } charging profile id ${chargingProfile.chargingProfileId.toString()} duration ${chargingProfile.chargingSchedule.duration.toString()} is greater than the recurrency time interval duration ${differenceInSeconds(
+      } charging profile id ${chargingProfileId} duration ${chargingSchedule.duration.toString()} is greater than the recurrency time interval duration ${differenceInSeconds(
         interval.end,
         interval.start
       ).toString()}`
     )
-    chargingProfile.chargingSchedule.duration = differenceInSeconds(interval.end, interval.start)
+    chargingSchedule.duration = differenceInSeconds(interval.end, interval.start)
   }
 }
 
index eb591836d2d5e9c4634a062fe2e0e00568e820f1..3db5a0a8ee98f62ea17fbcb073d5bc95ebf3a3b2 100644 (file)
@@ -188,8 +188,7 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
                   this.chargingStation,
                   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                   connectorId!,
-                  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                  transactionId!,
+                  convertToInt(transactionId),
                   configuredMeterValueSampleInterval != null
                     ? secondsToMilliseconds(convertToInt(configuredMeterValueSampleInterval.value))
                     : Constants.DEFAULT_METER_VALUES_INTERVAL
index 92fc90bd24d32ad5d08c76046466681265502a7c..b0c7bc0ab5cde428c434c3617ae966135c22242d 100644 (file)
@@ -9,6 +9,7 @@ import {
   type JsonObject,
   type JsonType,
   OCPP16ChargePointStatus,
+  type OCPP16MeterValue,
   OCPP16RequestCommand,
   type OCPP16StartTransactionRequest,
   OCPPVersion,
@@ -230,13 +231,14 @@ export class OCPP16RequestService extends OCPPRequestService {
           ...(chargingStation.stationInfo?.transactionDataMeterValues === true && {
             transactionData: OCPP16ServiceUtils.buildTransactionDataMeterValues(
               // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-              chargingStation.getConnectorStatus(connectorId!)!.transactionBeginMeterValue!,
+              chargingStation.getConnectorStatus(connectorId!)!
+                .transactionBeginMeterValue! as OCPP16MeterValue,
               OCPP16ServiceUtils.buildTransactionEndMeterValue(
                 chargingStation,
                 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                 connectorId!,
                 energyActiveImportRegister
-              )
+              ) as OCPP16MeterValue
             ),
           }),
           ...commandParams,
index a5c9519acb6fc8f85dc00cc65c0d0bdf4321d562..104057c4c5aaabc5b0d55fc88a0f6ac523be773d 100644 (file)
@@ -20,6 +20,7 @@ import {
   type OCPP16BootNotificationResponse,
   OCPP16ChargePointStatus,
   OCPP16IncomingRequestCommand,
+  type OCPP16MeterValue,
   type OCPP16MeterValuesRequest,
   type OCPP16MeterValuesResponse,
   OCPP16RequestCommand,
@@ -551,7 +552,7 @@ export class OCPP16ResponseService extends OCPPResponseService {
             chargingStation,
             transactionConnectorId,
             requestPayload.meterStop
-          ),
+          ) as OCPP16MeterValue,
         ],
         transactionId: requestPayload.transactionId,
       }))
index 21bcf31e05be32dae663aacea71f553f1ab19392..7cde80084531256ba9eefcde91a52d4f59a12e3b 100644 (file)
@@ -31,6 +31,7 @@ import {
   OCPP16MeterValueContext,
   OCPP16MeterValueUnit,
   OCPP16RequestCommand,
+  type OCPP16SampledValue,
   OCPP16StandardParametersKey,
   OCPP16StopTransactionReason,
   type OCPP16SupportedFeatureProfiles,
@@ -57,16 +58,18 @@ export class OCPP16ServiceUtils extends OCPPServiceUtils {
       chargingStation,
       connectorId
     )
-    const unitDivider =
-      sampledValueTemplate?.unit === OCPP16MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1
-    meterValue.sampledValue.push(
-      OCPP16ServiceUtils.buildSampledValue(
-        chargingStation.stationInfo?.ocppVersion,
-        sampledValueTemplate,
-        roundTo((meterStart ?? 0) / unitDivider, 4),
-        OCPP16MeterValueContext.TRANSACTION_BEGIN
+    if (sampledValueTemplate != null) {
+      const unitDivider =
+        sampledValueTemplate.unit === OCPP16MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1
+      meterValue.sampledValue.push(
+        OCPP16ServiceUtils.buildSampledValue(
+          chargingStation.stationInfo?.ocppVersion,
+          sampledValueTemplate,
+          roundTo((meterStart ?? 0) / unitDivider, 4),
+          OCPP16MeterValueContext.TRANSACTION_BEGIN
+        ) as OCPP16SampledValue
       )
-    )
+    }
     return meterValue
   }
 
index 159d93632d7f73e618a0a2314797ebe50ecddee5..e4db4f3fcad478301600a5710140260df7d30973 100644 (file)
@@ -83,12 +83,18 @@ export class OCPP20CertificateManager {
   private static readonly PEM_END_MARKER = '-----END CERTIFICATE-----'
 
   /**
-   * Computes hash data for a PEM certificate per RFC 6960 Section 4.1.1 CertID semantics
+   * Computes hash data for a PEM certificate per RFC 6960 §4.1.1 CertID semantics.
    *
    * Per RFC 6960, the CertID identifies a certificate by:
    * - issuerNameHash: Hash of the issuer's DN (from the subject certificate)
    * - issuerKeyHash: Hash of the issuer's public key (from the issuer certificate)
    * - serialNumber: The certificate's serial number
+   * @remarks
+   * **RFC 6960 §4.1.1 deviation**: Per RFC 6960, `issuerNameHash` must be the hash of the
+   * DER-encoded issuer distinguished name. This implementation hashes the string DN
+   * representation from `X509Certificate.issuer` as a simulation approximation. Full RFC 6960
+   * compliance would require ASN.1/DER encoding of the issuer name, which is outside the scope
+   * of this simulator. See also: mock CSR generation in the SignCertificate handler.
    * @param pemData - PEM-encoded certificate data
    * @param hashAlgorithm - Hash algorithm to use (default: SHA256)
    * @param issuerCertPem - Optional PEM-encoded issuer certificate for issuerKeyHash computation.
@@ -113,11 +119,12 @@ export class OCPP20CertificateManager {
       const firstCertPem = this.extractFirstCertificate(pemData)
       const x509 = new X509Certificate(firstCertPem)
 
-      // RFC 6960 4.1.1: issuerNameHash is the hash of the issuer's DN from the subject certificate
-      // Node.js X509Certificate.issuer provides the string representation of the issuer DN
+      // RFC 6960 §4.1.1 deviation: spec requires hash of DER-encoded issuer distinguished name.
+      // Using string DN from X509Certificate.issuer as simulation approximation
+      // (ASN.1/DER encoding of the issuer name is out of scope for this simulator).
       const issuerNameHash = createHash(algorithmName).update(x509.issuer).digest('hex')
 
-      // RFC 6960 4.1.1: issuerKeyHash is the hash of the issuer certificate's public key
+      // RFC 6960 §4.1.1: issuerKeyHash is the hash of the issuer certificate's public key
       // Determine which public key to use for issuerKeyHash
       let issuerPublicKeyDer: Buffer
 
@@ -371,7 +378,11 @@ export class OCPP20CertificateManager {
 
   /**
    * Computes fallback hash data when X509Certificate parsing fails.
-   * Uses the raw PEM content to derive hash values.
+   * Uses the raw PEM content to derive deterministic but non-RFC-compliant hash values.
+   * @remarks
+   * This fallback produces stable, unique identifiers for certificate matching purposes only.
+   * The hash values do not conform to RFC 6960 §4.1.1 CertID semantics since the raw DER
+   * content cannot be structurally parsed without X509Certificate support.
    * @param pemData - PEM-encoded certificate data
    * @param hashAlgorithm - Hash algorithm enum type for the response
    * @param algorithmName - Node.js crypto hash algorithm name
@@ -382,25 +393,22 @@ export class OCPP20CertificateManager {
     hashAlgorithm: HashAlgorithmEnumType,
     algorithmName: string
   ): CertificateHashDataType {
-    // Extract the base64 content between PEM markers
     const base64Content = pemData
       .replace(/-----BEGIN CERTIFICATE-----/, '')
       .replace(/-----END CERTIFICATE-----/, '')
       .replace(/\s/g, '')
 
-    // Compute hashes from the certificate content
     const contentBuffer = Buffer.from(base64Content, 'base64')
+
+    // Use first 64 bytes as issuer name proxy: in DER-encoded X.509, the issuer DN
+    // typically resides within this range, providing a stable hash for matching.
+    const issuerNameSliceEnd = Math.min(64, contentBuffer.length)
     const issuerNameHash = createHash(algorithmName)
-      .update(contentBuffer.subarray(0, Math.min(64, contentBuffer.length)))
+      .update(contentBuffer.subarray(0, issuerNameSliceEnd))
       .digest('hex')
     const issuerKeyHash = createHash(algorithmName).update(contentBuffer).digest('hex')
 
-    // Generate a serial number from the content hash
-    const serialNumber = createHash('sha256')
-      .update(pemData)
-      .digest('hex')
-      .substring(0, 16)
-      .toUpperCase()
+    const serialNumber = this.generateFallbackSerialNumber(pemData)
 
     return {
       hashAlgorithm,
index 9640a22eb91919874633370603b2daef793777d9..636932c4b9a84752db3e97be5db4a5b33d4fb233 100644 (file)
@@ -137,6 +137,12 @@ export class OCPP20Constants extends OCPPConstants {
     // { from: OCPP20ConnectorStatusEnumType.Faulted, to: OCPP20ConnectorStatusEnumType.Faulted }
   ])
 
+  /**
+   * Default timeout in milliseconds for async OCPP 2.0 handler operations
+   * (e.g., certificate file I/O). Prevents handlers from hanging indefinitely.
+   */
+  static readonly HANDLER_TIMEOUT_MS = 30_000
+
   static readonly TriggerReasonMapping: readonly TriggerReasonMap[] = Object.freeze([
     // Priority 1: Remote Commands (highest priority)
     {
index f87b528a8485ce01907ff6520618eacb4b33f015..671c212ad6b97fe9e25c6662a0d29f09fbe84096 100644 (file)
@@ -31,6 +31,9 @@ import {
   InstallCertificateStatusEnumType,
   InstallCertificateUseEnumType,
   type JsonType,
+  MessageTriggerEnumType,
+  type OCPP20BootNotificationRequest,
+  type OCPP20BootNotificationResponse,
   type OCPP20CertificateSignedRequest,
   type OCPP20CertificateSignedResponse,
   type OCPP20ClearCacheResponse,
@@ -45,6 +48,8 @@ import {
   type OCPP20GetInstalledCertificateIdsResponse,
   type OCPP20GetVariablesRequest,
   type OCPP20GetVariablesResponse,
+  type OCPP20HeartbeatRequest,
+  type OCPP20HeartbeatResponse,
   OCPP20IncomingRequestCommand,
   type OCPP20InstallCertificateRequest,
   type OCPP20InstallCertificateResponse,
@@ -60,8 +65,15 @@ import {
   type OCPP20ResetResponse,
   type OCPP20SetVariablesRequest,
   type OCPP20SetVariablesResponse,
+  type OCPP20StatusNotificationRequest,
+  type OCPP20StatusNotificationResponse,
+  type OCPP20TriggerMessageRequest,
+  type OCPP20TriggerMessageResponse,
+  type OCPP20UnlockConnectorRequest,
+  type OCPP20UnlockConnectorResponse,
   OCPPVersion,
   ReasonCodeEnumType,
+  RegistrationStatusEnumType,
   ReportBaseEnumType,
   type ReportDataType,
   RequestStartStopStatusEnumType,
@@ -69,6 +81,8 @@ import {
   ResetStatusEnumType,
   SetVariableStatusEnumType,
   StopTransactionReason,
+  TriggerMessageStatusEnumType,
+  UnlockStatusEnumType,
 } from '../../../types/index.js'
 import {
   OCPP20ChargingProfileKindEnumType,
@@ -76,16 +90,13 @@ import {
   OCPP20ChargingRateUnitEnumType,
   OCPP20ReasonEnumType,
 } from '../../../types/ocpp/2.0/Transaction.js'
-import { StandardParametersKey } from '../../../types/ocpp/Configuration.js'
 import {
   Constants,
-  convertToIntOrNaN,
   generateUUID,
   isAsyncFunction,
   logger,
   validateUUID,
 } from '../../../utils/index.js'
-import { getConfigurationKey } from '../../ConfigurationKeyUtils.js'
 import {
   getIdTagsFile,
   hasPendingReservation,
@@ -107,50 +118,6 @@ import { getVariableMetadata, VARIABLE_REGISTRY } from './OCPP20VariableRegistry
 
 const moduleName = 'OCPP20IncomingRequestService'
 
-/**
- * OCPP 2.0+ Incoming Request Service - handles and processes all incoming requests
- * from the Central System (CSMS) to the Charging Station using OCPP 2.0+ protocol.
- *
- * This service class is responsible for:
- * - **Request Reception**: Receiving and routing OCPP 2.0+ incoming requests from CSMS
- * - **Payload Validation**: Validating incoming request payloads against OCPP 2.0+ JSON schemas
- * - **Request Processing**: Executing business logic for each OCPP 2.0+ request type
- * - **Response Generation**: Creating and sending appropriate responses back to CSMS
- * - **Enhanced Features**: Supporting advanced OCPP 2.0+ features like variable management
- *
- * Supported OCPP 2.0+ Incoming Request Types:
- * - **Transaction Management**: RequestStartTransaction, RequestStopTransaction
- * - **Configuration Management**: SetVariables, GetVariables, GetBaseReport
- * - **Security Operations**: CertificatesSigned, SecurityEventNotification
- * - **Charging Management**: SetChargingProfile, ClearChargingProfile, GetChargingProfiles
- * - **Diagnostics**: TriggerMessage, GetLog, UpdateFirmware
- * - **Display Management**: SetDisplayMessage, ClearDisplayMessage
- * - **Customer Management**: ClearCache, SendLocalList
- *
- * Key OCPP 2.0+ Enhancements:
- * - **Variable Model**: Advanced configuration through standardized variable system
- * - **Enhanced Security**: Improved authentication and authorization mechanisms
- * - **Rich Messaging**: Support for display messages and customer information
- * - **Advanced Monitoring**: Comprehensive logging and diagnostic capabilities
- * - **Flexible Charging**: Enhanced charging profile management and scheduling
- *
- * Architecture Pattern:
- * This class extends OCPPIncomingRequestService and implements OCPP 2.0+-specific
- * request handling logic. It integrates with the OCPP20VariableManager for advanced
- * configuration management and maintains backward compatibility concepts while
- * providing next-generation OCPP features.
- *
- * Validation Workflow:
- * 1. Incoming request received and parsed
- * 2. Payload validated against OCPP 2.0+ JSON schema
- * 3. Request routed to appropriate handler method
- * 4. Business logic executed with variable model integration
- * 5. Response payload validated and sent back to CSMS
- * @see {@link validatePayload} Request payload validation method
- * @see {@link handleRequestStartTransaction} Example OCPP 2.0+ request handler
- * @see {@link OCPP20VariableManager} Variable management integration
- */
-
 export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
   protected payloadValidatorFunctions: Map<OCPP20IncomingRequestCommand, ValidateFunction<JsonType>>
 
@@ -167,50 +134,52 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     this.incomingRequestHandlers = new Map<OCPP20IncomingRequestCommand, IncomingRequestHandler>([
       [
         OCPP20IncomingRequestCommand.CERTIFICATE_SIGNED,
-        this.handleRequestCertificateSigned.bind(this) as unknown as IncomingRequestHandler,
+        this.toHandler(this.handleRequestCertificateSigned.bind(this)),
       ],
       [
         OCPP20IncomingRequestCommand.CLEAR_CACHE,
-        this.handleRequestClearCache.bind(this) as unknown as IncomingRequestHandler,
+        this.toHandler(this.handleRequestClearCache.bind(this)),
       ],
       [
         OCPP20IncomingRequestCommand.DELETE_CERTIFICATE,
-        this.handleRequestDeleteCertificate.bind(this) as unknown as IncomingRequestHandler,
+        this.toHandler(this.handleRequestDeleteCertificate.bind(this)),
       ],
       [
         OCPP20IncomingRequestCommand.GET_BASE_REPORT,
-        this.handleRequestGetBaseReport.bind(this) as unknown as IncomingRequestHandler,
+        this.toHandler(this.handleRequestGetBaseReport.bind(this)),
       ],
-
       [
         OCPP20IncomingRequestCommand.GET_INSTALLED_CERTIFICATE_IDS,
-        this.handleRequestGetInstalledCertificateIds.bind(
-          this
-        ) as unknown as IncomingRequestHandler,
+        this.toHandler(this.handleRequestGetInstalledCertificateIds.bind(this)),
       ],
       [
         OCPP20IncomingRequestCommand.GET_VARIABLES,
-        this.handleRequestGetVariables.bind(this) as unknown as IncomingRequestHandler,
+        this.toHandler(this.handleRequestGetVariables.bind(this)),
       ],
       [
         OCPP20IncomingRequestCommand.INSTALL_CERTIFICATE,
-        this.handleRequestInstallCertificate.bind(this) as unknown as IncomingRequestHandler,
+        this.toHandler(this.handleRequestInstallCertificate.bind(this)),
       ],
       [
         OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
-        this.handleRequestStartTransaction.bind(this) as unknown as IncomingRequestHandler,
+        this.toHandler(this.handleRequestStartTransaction.bind(this)),
       ],
       [
         OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
-        this.handleRequestStopTransaction.bind(this) as unknown as IncomingRequestHandler,
+        this.toHandler(this.handleRequestStopTransaction.bind(this)),
       ],
+      [OCPP20IncomingRequestCommand.RESET, this.toHandler(this.handleRequestReset.bind(this))],
       [
-        OCPP20IncomingRequestCommand.RESET,
-        this.handleRequestReset.bind(this) as unknown as IncomingRequestHandler,
+        OCPP20IncomingRequestCommand.SET_VARIABLES,
+        this.toHandler(this.handleRequestSetVariables.bind(this)),
       ],
       [
-        OCPP20IncomingRequestCommand.SET_VARIABLES,
-        this.handleRequestSetVariables.bind(this) as unknown as IncomingRequestHandler,
+        OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+        this.toHandler(this.handleRequestTriggerMessage.bind(this)),
+      ],
+      [
+        OCPP20IncomingRequestCommand.UNLOCK_CONNECTOR,
+        this.toHandler(this.handleRequestUnlockConnector.bind(this)),
       ],
     ])
     this.payloadValidatorFunctions = OCPP20ServiceUtils.createPayloadValidatorMap(
@@ -251,27 +220,8 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
 
     const variableManager = OCPP20VariableManager.getInstance()
 
-    // Enforce ItemsPerMessage and BytesPerMessage limits if configured
-    let enforceItemsLimit = 0
-    let enforceBytesLimit = 0
-    try {
-      const itemsCfg = getConfigurationKey(
-        chargingStation,
-        OCPP20RequiredVariableName.ItemsPerMessage as unknown as StandardParametersKey
-      )?.value
-      const bytesCfg = getConfigurationKey(
-        chargingStation,
-        OCPP20RequiredVariableName.BytesPerMessage as unknown as StandardParametersKey
-      )?.value
-      if (itemsCfg && /^\d+$/.test(itemsCfg)) {
-        enforceItemsLimit = convertToIntOrNaN(itemsCfg)
-      }
-      if (bytesCfg && /^\d+$/.test(bytesCfg)) {
-        enforceBytesLimit = convertToIntOrNaN(bytesCfg)
-      }
-    } catch {
-      /* ignore */
-    }
+    const { bytesLimit: enforceBytesLimit, itemsLimit: enforceItemsLimit } =
+      OCPP20ServiceUtils.readMessageLimits(chargingStation)
 
     const variableData = commandPayload.getVariableData
     const preEnforcement = OCPP20ServiceUtils.enforceMessageLimits(
@@ -339,31 +289,11 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       setVariableResult: [],
     }
 
-    // Enforce ItemsPerMessageSetVariables and BytesPerMessageSetVariables limits if configured
-    let enforceItemsLimit = 0
-    let enforceBytesLimit = 0
-    try {
-      const itemsCfg = getConfigurationKey(
-        chargingStation,
-        OCPP20RequiredVariableName.ItemsPerMessage as unknown as StandardParametersKey
-      )?.value
-      const bytesCfg = getConfigurationKey(
-        chargingStation,
-        OCPP20RequiredVariableName.BytesPerMessage as unknown as StandardParametersKey
-      )?.value
-      if (itemsCfg && /^\d+$/.test(itemsCfg)) {
-        enforceItemsLimit = convertToIntOrNaN(itemsCfg)
-      }
-      if (bytesCfg && /^\d+$/.test(bytesCfg)) {
-        enforceBytesLimit = convertToIntOrNaN(bytesCfg)
-      }
-    } catch {
-      /* ignore */
-    }
+    const { bytesLimit: enforceBytesLimit, itemsLimit: enforceItemsLimit } =
+      OCPP20ServiceUtils.readMessageLimits(chargingStation)
 
     const variableManager = OCPP20VariableManager.getInstance()
 
-    // Items per message enforcement
     const variableData = commandPayload.setVariableData
     const preEnforcement = OCPP20ServiceUtils.enforceMessageLimits(
       chargingStation,
@@ -529,7 +459,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
    * @param chargingStation - The charging station instance
    * @returns Promise resolving to ClearCacheResponse
    */
-  protected override async handleRequestClearCache (
+  protected async handleRequestClearCache (
     chargingStation: ChargingStation
   ): Promise<OCPP20ClearCacheResponse> {
     try {
@@ -560,7 +490,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     chargingStation: ChargingStation,
     reportBase: ReportBaseEnumType
   ): ReportDataType[] {
-    // Validate reportBase parameter
     if (!Object.values(ReportBaseEnumType).includes(reportBase)) {
       logger.warn(
         `${chargingStation.logPrefix()} ${moduleName}.buildReportData: Invalid reportBase '${reportBase}'`
@@ -572,7 +501,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
 
     switch (reportBase) {
       case ReportBaseEnumType.ConfigurationInventory:
-        // Include OCPP configuration keys
         if (chargingStation.ocppConfiguration?.configurationKey) {
           for (const configKey of chargingStation.ocppConfiguration.configurationKey) {
             reportData.push({
@@ -598,7 +526,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         break
 
       case ReportBaseEnumType.FullInventory:
-        // 1. Charging Station information
         if (chargingStation.stationInfo) {
           const stationInfo = chargingStation.stationInfo
           if (stationInfo.chargePointModel) {
@@ -646,7 +573,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
           }
         }
 
-        // 2. OCPP configuration
         if (chargingStation.ocppConfiguration?.configurationKey) {
           for (const configKey of chargingStation.ocppConfiguration.configurationKey) {
             const variableAttributes = []
@@ -667,26 +593,21 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
           }
         }
 
-        // 3. Registered OCPP 2.0.1 variables
         try {
           const variableManager = OCPP20VariableManager.getInstance()
-          // Build getVariableData array from VARIABLE_REGISTRY metadata
           const getVariableData: OCPP20GetVariablesRequest['getVariableData'] = []
           for (const variableMetadata of Object.values(VARIABLE_REGISTRY)) {
-            // Include instance-scoped metadata; the OCPP Variable type supports instance under variable
             const variableDescriptor: { instance?: string; name: string } = {
               name: variableMetadata.variable,
             }
             if (variableMetadata.instance) {
               variableDescriptor.instance = variableMetadata.instance
             }
-            // Always request Actual first
             getVariableData.push({
               attributeType: AttributeEnumType.Actual,
               component: { name: variableMetadata.component },
               variable: variableDescriptor,
             })
-            // Request MinSet/MaxSet only if supported by metadata
             if (variableMetadata.supportedAttributes.includes(AttributeEnumType.MinSet)) {
               getVariableData.push({
                 attributeType: AttributeEnumType.MinSet,
@@ -703,7 +624,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
             }
           }
           const getResults = variableManager.getVariables(chargingStation, getVariableData)
-          // Group results by component+variable preserving attribute ordering Actual, MinSet, MaxSet
           const grouped = new Map<
             string,
             {
@@ -736,7 +656,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
               }
             }
           }
-          // Normalize attribute ordering
           for (const entry of grouped.values()) {
             entry.attributes.sort((a, b) => {
               const order = [
@@ -765,7 +684,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
           )
         }
 
-        // 4. EVSE and connector information
         if (chargingStation.hasEvses) {
           for (const [evseId, evse] of chargingStation.evses) {
             reportData.push({
@@ -802,7 +720,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
             }
           }
         } else {
-          // Fallback to connectors if no EVSE structure
           for (const [connectorId, connector] of chargingStation.connectors) {
             if (connectorId > 0) {
               reportData.push({
@@ -891,7 +808,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
             })
           }
         } else {
-          // Fallback to connectors if no EVSE structure
           for (const [connectorId, connector] of chargingStation.connectors) {
             if (connectorId > 0) {
               reportData.push({
@@ -964,12 +880,12 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return {
         status: GenericStatus.Rejected,
         statusInfo: {
+          additionalInfo: 'Certificate manager is not available on this charging station',
           reasonCode: ReasonCodeEnumType.InternalError,
         },
       }
     }
 
-    // Validate certificate chain format
     if (!chargingStation.certificateManager.validateCertificateFormat(certificateChain)) {
       logger.warn(
         `${chargingStation.logPrefix()} ${moduleName}.handleRequestCertificateSigned: Invalid PEM format for certificate chain`
@@ -977,12 +893,12 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return {
         status: GenericStatus.Rejected,
         statusInfo: {
+          additionalInfo: 'Certificate PEM format is invalid or malformed',
           reasonCode: ReasonCodeEnumType.InvalidCertificate,
         },
       }
     }
 
-    // Store certificate chain
     try {
       const result = chargingStation.certificateManager.storeCertificate(
         chargingStation.stationInfo?.hashId ?? '',
@@ -990,10 +906,8 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         certificateChain
       )
 
-      // Handle both Promise and synchronous returns, and both boolean and object results
       const storeResult = result instanceof Promise ? await result : result
 
-      // Handle both boolean (test mock) and object (real implementation) results
       const success = typeof storeResult === 'boolean' ? storeResult : storeResult.success
 
       if (!success) {
@@ -1003,12 +917,12 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         return {
           status: GenericStatus.Rejected,
           statusInfo: {
+            additionalInfo: 'Certificate storage rejected the certificate chain as invalid',
             reasonCode: ReasonCodeEnumType.InvalidCertificate,
           },
         }
       }
 
-      // For ChargingStationCertificate, trigger websocket reconnect to use the new certificate
       const effectiveCertificateType =
         certificateType ?? CertificateSigningUseEnumType.ChargingStationCertificate
       if (effectiveCertificateType === CertificateSigningUseEnumType.ChargingStationCertificate) {
@@ -1032,6 +946,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return {
         status: GenericStatus.Rejected,
         statusInfo: {
+          additionalInfo: 'Failed to store certificate chain due to a storage error',
           reasonCode: ReasonCodeEnumType.OutOfStorage,
         },
       }
@@ -1058,6 +973,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return {
         status: DeleteCertificateStatusEnumType.Failed,
         statusInfo: {
+          additionalInfo: 'Certificate manager is not available on this charging station',
           reasonCode: ReasonCodeEnumType.InternalError,
         },
       }
@@ -1069,10 +985,8 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         certificateHashData
       )
 
-      // Handle both Promise and synchronous returns
       const deleteResult = result instanceof Promise ? await result : result
 
-      // Check the status field for the result
       if (deleteResult.status === DeleteCertificateStatusEnumType.NotFound) {
         logger.info(
           `${chargingStation.logPrefix()} ${moduleName}.handleRequestDeleteCertificate: Certificate not found`
@@ -1091,10 +1005,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         }
       }
 
-      // Failed status
       return {
         status: DeleteCertificateStatusEnumType.Failed,
         statusInfo: {
+          additionalInfo: 'Certificate deletion operation returned a failed status',
           reasonCode: ReasonCodeEnumType.InternalError,
         },
       }
@@ -1106,6 +1020,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return {
         status: DeleteCertificateStatusEnumType.Failed,
         statusInfo: {
+          additionalInfo: 'Certificate deletion failed due to an unexpected error',
           reasonCode: ReasonCodeEnumType.InternalError,
         },
       }
@@ -1130,7 +1045,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       }
     }
 
-    // Cache report data for subsequent NotifyReport requests to avoid recomputation
     const cached = this.reportDataCache.get(commandPayload.requestId)
     const reportData = cached ?? this.buildReportData(chargingStation, commandPayload.reportBase)
     if (!cached && reportData.length > 0) {
@@ -1169,6 +1083,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return {
         status: GetInstalledCertificateStatusEnumType.NotFound,
         statusInfo: {
+          additionalInfo: 'Certificate manager is not available on this charging station',
           reasonCode: ReasonCodeEnumType.InternalError,
         },
       }
@@ -1209,6 +1124,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return {
         status: GetInstalledCertificateStatusEnumType.NotFound,
         statusInfo: {
+          additionalInfo: 'Failed to retrieve installed certificates due to an unexpected error',
           reasonCode: ReasonCodeEnumType.InternalError,
         },
       }
@@ -1228,6 +1144,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return {
         status: InstallCertificateStatusEnumType.Failed,
         statusInfo: {
+          additionalInfo: 'Certificate manager is not available on this charging station',
           reasonCode: ReasonCodeEnumType.InternalError,
         },
       }
@@ -1240,19 +1157,23 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return {
         status: InstallCertificateStatusEnumType.Rejected,
         statusInfo: {
+          additionalInfo: 'Certificate PEM format is invalid or malformed',
           reasonCode: ReasonCodeEnumType.InvalidCertificate,
         },
       }
     }
 
     try {
-      const methodResult = chargingStation.certificateManager.storeCertificate(
+      const rawResult = chargingStation.certificateManager.storeCertificate(
         chargingStation.stationInfo?.hashId ?? '',
         certificateType,
         certificate
       )
-      const storeResult: StoreCertificateResult =
-        methodResult instanceof Promise ? await methodResult : methodResult
+      const resultPromise: Promise<StoreCertificateResult> =
+        rawResult instanceof Promise
+          ? withTimeout(rawResult, OCPP20Constants.HANDLER_TIMEOUT_MS, 'storeCertificate')
+          : Promise.resolve(rawResult)
+      const storeResult: StoreCertificateResult = await resultPromise
 
       if (!storeResult.success) {
         logger.warn(
@@ -1261,6 +1182,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         return {
           status: InstallCertificateStatusEnumType.Rejected,
           statusInfo: {
+            additionalInfo: 'Certificate storage rejected the certificate as invalid',
             reasonCode: ReasonCodeEnumType.InvalidCertificate,
           },
         }
@@ -1280,6 +1202,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return {
         status: InstallCertificateStatusEnumType.Failed,
         statusInfo: {
+          additionalInfo: 'Failed to store certificate due to a storage error',
           reasonCode: ReasonCodeEnumType.OutOfStorage,
         },
       }
@@ -1296,8 +1219,40 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
 
     const { evseId, type } = commandPayload
 
+    const variableManager = OCPP20VariableManager.getInstance()
+    const allowResetResults = variableManager.getVariables(chargingStation, [
+      {
+        component: { name: OCPP20ComponentName.EVSE },
+        variable: { name: 'AllowReset' },
+      },
+    ])
+    if (allowResetResults.length > 0 && allowResetResults[0].attributeValue === 'false') {
+      logger.warn(
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: AllowReset is false, rejecting reset request`
+      )
+      return {
+        status: ResetStatusEnumType.Rejected,
+        statusInfo: {
+          additionalInfo: 'AllowReset variable is set to false',
+          reasonCode: ReasonCodeEnumType.NotEnabled,
+        },
+      }
+    }
+
+    if (this.hasFirmwareUpdateInProgress(chargingStation)) {
+      logger.warn(
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Firmware update in progress, rejecting reset request`
+      )
+      return {
+        status: ResetStatusEnumType.Rejected,
+        statusInfo: {
+          additionalInfo: 'Firmware update is in progress',
+          reasonCode: ReasonCodeEnumType.FwUpdateInProgress,
+        },
+      }
+    }
+
     if (evseId !== undefined && evseId > 0) {
-      // Check if the charging station supports EVSE-specific reset
       if (!chargingStation.hasEvses) {
         logger.warn(
           `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Charging station does not support EVSE-specific reset`
@@ -1311,7 +1266,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         }
       }
 
-      // Check if the EVSE exists
       const evseExists = chargingStation.evses.has(evseId)
       if (!evseExists) {
         logger.warn(
@@ -1327,10 +1281,8 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       }
     }
 
-    // Check for active transactions
     const hasActiveTransactions = chargingStation.getNumberOfRunningTransactions() > 0
 
-    // Check for EVSE-specific active transactions if evseId is provided
     let evseHasActiveTransactions = false
     if (evseId !== undefined && evseId > 0) {
       const evse = chargingStation.getEvseStatus(evseId)
@@ -1341,14 +1293,12 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
 
     try {
       if (type === ResetEnumType.Immediate) {
-        if (evseId !== undefined) {
-          // EVSE-specific immediate reset
+        if (evseId !== undefined && evseId > 0) {
           if (evseHasActiveTransactions) {
             logger.info(
               `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Immediate EVSE reset with active transaction, will terminate transaction and reset EVSE ${evseId.toString()}`
             )
 
-            // Implement EVSE-specific transaction termination
             await this.terminateEvseTransactions(
               chargingStation,
               evseId,
@@ -1360,7 +1310,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
               status: ResetStatusEnumType.Accepted,
             }
           } else {
-            // Reset EVSE immediately
             logger.info(
               `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Immediate EVSE reset without active transactions for EVSE ${evseId.toString()}`
             )
@@ -1372,13 +1321,11 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
             }
           }
         } else {
-          // Charging station immediate reset
           if (hasActiveTransactions) {
             logger.info(
               `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Immediate reset with active transactions, will terminate transactions and reset`
             )
 
-            // Implement proper transaction termination with TransactionEventRequest
             await this.terminateAllTransactions(
               chargingStation,
               OCPP20ReasonEnumType.ImmediateReset
@@ -1398,7 +1345,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
               `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Immediate reset without active transactions`
             )
 
-            // Send StatusNotification(Unavailable) for all connectors
             this.sendAllConnectorsStatusNotifications(
               chargingStation,
               OCPP20ConnectorStatusEnumType.Unavailable
@@ -1416,23 +1362,19 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
           }
         }
       } else {
-        // OnIdle reset
-        if (evseId !== undefined) {
-          // EVSE-specific OnIdle reset
+        if (evseId !== undefined && evseId > 0) {
           const evse = chargingStation.getEvseStatus(evseId)
           if (evse != null && !this.isEvseIdle(chargingStation, evse)) {
             logger.info(
               `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: OnIdle EVSE reset scheduled for EVSE ${evseId.toString()}, waiting for idle state`
             )
 
-            // Monitor EVSE for idle state and schedule reset when idle
             this.scheduleEvseResetOnIdle(chargingStation, evseId)
 
             return {
               status: ResetStatusEnumType.Scheduled,
             }
           } else {
-            // EVSE is idle, reset immediately
             logger.info(
               `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: OnIdle EVSE reset - EVSE ${evseId.toString()} is idle, resetting immediately`
             )
@@ -1444,7 +1386,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
             }
           }
         } else {
-          // Charging station OnIdle reset
           if (!this.isChargingStationIdle(chargingStation)) {
             logger.info(
               `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: OnIdle reset scheduled, waiting for idle state`
@@ -1456,7 +1397,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
               status: ResetStatusEnumType.Scheduled,
             }
           } else {
-            // Charging station is idle, reset immediately
             logger.info(
               `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: OnIdle reset - charging station is idle, resetting immediately`
             )
@@ -1506,7 +1446,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Remote start transaction request received on EVSE ${evseId?.toString() ?? 'undefined'} with idToken ${idToken.idToken} and remoteStartId ${remoteStartId.toString()}`
     )
 
-    // Validate that EVSE ID is provided
     if (evseId == null) {
       const errorMsg = 'EVSE ID is required for RequestStartTransaction'
       logger.warn(
@@ -1520,7 +1459,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       )
     }
 
-    // Get the first connector for this EVSE
     const evse = chargingStation.getEvseStatus(evseId)
     if (evse == null) {
       const errorMsg = `EVSE ${evseId.toString()} does not exist on charging station`
@@ -1551,7 +1489,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       )
     }
 
-    // Check if connector is available for a new transaction
     if (connectorStatus.transactionStarted === true) {
       logger.warn(
         `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Connector ${connectorId.toString()} already has an active transaction`
@@ -1562,7 +1499,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       }
     }
 
-    // Authorize idToken
     let isAuthorized = false
     try {
       isAuthorized = this.isIdTokenAuthorized(chargingStation, idToken)
@@ -1587,7 +1523,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       }
     }
 
-    // Authorize groupIdToken if provided
     if (groupIdToken != null) {
       let isGroupAuthorized = false
       try {
@@ -1614,7 +1549,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       }
     }
 
-    // Validate charging profile if provided
     if (chargingProfile != null) {
       // OCPP 2.0.1 §2.10: RequestStartTransaction requires chargingProfilePurpose = TxProfile
       if (
@@ -1666,7 +1600,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     const transactionId = generateUUID()
 
     try {
-      // Set connector transaction state
       logger.debug(
         `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Setting transaction state for connector ${connectorId.toString()}, transaction ID: ${transactionId}`
       )
@@ -1680,7 +1613,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Transaction state set successfully for connector ${connectorId.toString()}`
       )
 
-      // Update connector status to Occupied
       logger.debug(
         `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Updating connector ${connectorId.toString()} status to Occupied`
       )
@@ -1691,7 +1623,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         evseId
       )
 
-      // Store charging profile if provided
       if (chargingProfile != null) {
         connectorStatus.chargingProfiles ??= []
         connectorStatus.chargingProfiles.push(chargingProfile)
@@ -1792,6 +1723,255 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     }
   }
 
+  private handleRequestTriggerMessage (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20TriggerMessageRequest
+  ): OCPP20TriggerMessageResponse {
+    try {
+      const { evse, requestedMessage } = commandPayload
+
+      logger.debug(
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: TriggerMessage received for '${requestedMessage}'${evse?.id !== undefined ? ` on EVSE ${evse.id.toString()}` : ''}`
+      )
+
+      if (evse?.id !== undefined && evse.id > 0) {
+        if (!chargingStation.hasEvses) {
+          return {
+            status: TriggerMessageStatusEnumType.Rejected,
+            statusInfo: {
+              additionalInfo: 'Charging station does not support EVSEs',
+              reasonCode: ReasonCodeEnumType.UnsupportedRequest,
+            },
+          }
+        }
+        if (!chargingStation.evses.has(evse.id)) {
+          return {
+            status: TriggerMessageStatusEnumType.Rejected,
+            statusInfo: {
+              additionalInfo: `EVSE ${evse.id.toString()} does not exist`,
+              reasonCode: ReasonCodeEnumType.UnknownEvse,
+            },
+          }
+        }
+      }
+
+      switch (requestedMessage) {
+        case MessageTriggerEnumType.BootNotification:
+          // F06.FR.17: Reject BootNotification trigger if last boot was already Accepted
+          if (
+            chargingStation.bootNotificationResponse?.status === RegistrationStatusEnumType.ACCEPTED
+          ) {
+            return {
+              status: TriggerMessageStatusEnumType.Rejected,
+              statusInfo: {
+                additionalInfo: 'BootNotification already accepted (F06.FR.17)',
+                reasonCode: ReasonCodeEnumType.NotEnabled,
+              },
+            }
+          }
+          chargingStation.ocppRequestService
+            .requestHandler<
+              OCPP20BootNotificationRequest,
+              OCPP20BootNotificationResponse
+            >(chargingStation, OCPP20RequestCommand.BOOT_NOTIFICATION, chargingStation.bootNotificationRequest as OCPP20BootNotificationRequest, { skipBufferingOnError: true, triggerMessage: true })
+            .catch((error: unknown) => {
+              logger.error(
+                `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending BootNotification:`,
+                error
+              )
+            })
+          return { status: TriggerMessageStatusEnumType.Accepted }
+
+        case MessageTriggerEnumType.Heartbeat:
+          chargingStation.ocppRequestService
+            .requestHandler<
+              OCPP20HeartbeatRequest,
+              OCPP20HeartbeatResponse
+            >(chargingStation, OCPP20RequestCommand.HEARTBEAT, {}, { skipBufferingOnError: true, triggerMessage: true })
+            .catch((error: unknown) => {
+              logger.error(
+                `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending Heartbeat:`,
+                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)
+            const connectorStatus = evseStatus?.connectors.get(evse.connectorId)
+            const resolvedStatus =
+              connectorStatus?.status != null
+                ? (connectorStatus.status as unknown as OCPP20ConnectorStatusEnumType)
+                : OCPP20ConnectorStatusEnumType.Available
+            chargingStation.ocppRequestService
+              .requestHandler<OCPP20StatusNotificationRequest, OCPP20StatusNotificationResponse>(
+                chargingStation,
+                OCPP20RequestCommand.STATUS_NOTIFICATION,
+                {
+                  connectorId: evse.connectorId,
+                  connectorStatus: resolvedStatus,
+                  evseId: evse.id,
+                  timestamp: new Date(),
+                },
+                { skipBufferingOnError: true, triggerMessage: true }
+              )
+              .catch((error: unknown) => {
+                logger.error(
+                  `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending StatusNotification:`,
+                  error
+                )
+              })
+          } else {
+            if (chargingStation.hasEvses) {
+              for (const [evseId, evseStatus] of chargingStation.evses) {
+                if (evseId > 0) {
+                  for (const [connectorId, connectorStatus] of evseStatus.connectors) {
+                    const resolvedConnectorStatus =
+                      connectorStatus.status != null
+                        ? (connectorStatus.status as unknown as OCPP20ConnectorStatusEnumType)
+                        : OCPP20ConnectorStatusEnumType.Available
+                    chargingStation.ocppRequestService
+                      .requestHandler<
+                        OCPP20StatusNotificationRequest,
+                        OCPP20StatusNotificationResponse
+                      >(
+                        chargingStation,
+                        OCPP20RequestCommand.STATUS_NOTIFICATION,
+                        {
+                          connectorId,
+                          connectorStatus: resolvedConnectorStatus,
+                          evseId,
+                          timestamp: new Date(),
+                        },
+                        { skipBufferingOnError: true, triggerMessage: true }
+                      )
+                      .catch((error: unknown) => {
+                        logger.error(
+                          `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending StatusNotification:`,
+                          error
+                        )
+                      })
+                  }
+                }
+              }
+            }
+          }
+          return { status: TriggerMessageStatusEnumType.Accepted }
+
+        default:
+          logger.warn(
+            `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Unsupported message trigger '${requestedMessage}'`
+          )
+          return {
+            status: TriggerMessageStatusEnumType.NotImplemented,
+            statusInfo: {
+              additionalInfo: `Message trigger '${requestedMessage}' is not implemented`,
+              reasonCode: ReasonCodeEnumType.UnsupportedRequest,
+            },
+          }
+      }
+    } catch (error) {
+      logger.error(
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error handling trigger message request:`,
+        error
+      )
+
+      return {
+        status: TriggerMessageStatusEnumType.Rejected,
+        statusInfo: {
+          additionalInfo: 'Internal error occurred while processing trigger message request',
+          reasonCode: ReasonCodeEnumType.InternalError,
+        },
+      }
+    }
+  }
+
+  private async handleRequestUnlockConnector (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20UnlockConnectorRequest
+  ): Promise<OCPP20UnlockConnectorResponse> {
+    try {
+      const { connectorId, evseId } = commandPayload
+
+      logger.debug(
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestUnlockConnector: UnlockConnector received for EVSE ${evseId.toString()} connector ${connectorId.toString()}`
+      )
+
+      if (!chargingStation.hasEvses) {
+        return {
+          status: UnlockStatusEnumType.UnknownConnector,
+          statusInfo: {
+            additionalInfo: 'Charging station does not support EVSEs',
+            reasonCode: ReasonCodeEnumType.UnsupportedRequest,
+          },
+        }
+      }
+
+      if (!chargingStation.evses.has(evseId)) {
+        return {
+          status: UnlockStatusEnumType.UnknownConnector,
+          statusInfo: {
+            additionalInfo: `EVSE ${evseId.toString()} does not exist`,
+            reasonCode: ReasonCodeEnumType.UnknownEvse,
+          },
+        }
+      }
+
+      const evseStatus = chargingStation.getEvseStatus(evseId)
+      if (evseStatus?.connectors.has(connectorId) !== true) {
+        return {
+          status: UnlockStatusEnumType.UnknownConnector,
+          statusInfo: {
+            additionalInfo: `Connector ${connectorId.toString()} does not exist on EVSE ${evseId.toString()}`,
+            reasonCode: ReasonCodeEnumType.UnknownConnectorId,
+          },
+        }
+      }
+
+      // F05.FR.02: Check for ongoing authorized transaction on the specified connector
+      const targetConnector = evseStatus.connectors.get(connectorId)
+      if (targetConnector?.transactionId != null) {
+        logger.info(
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestUnlockConnector: Ongoing authorized transaction on connector ${connectorId.toString()} of EVSE ${evseId.toString()}`
+        )
+        return {
+          status: UnlockStatusEnumType.OngoingAuthorizedTransaction,
+          statusInfo: {
+            additionalInfo: `Connector ${connectorId.toString()} on EVSE ${evseId.toString()} has an ongoing authorized transaction`,
+            reasonCode: ReasonCodeEnumType.TxInProgress,
+          },
+        }
+      }
+
+      logger.info(
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestUnlockConnector: Unlocking connector ${connectorId.toString()} on EVSE ${evseId.toString()}`
+      )
+
+      await sendAndSetConnectorStatus(
+        chargingStation,
+        connectorId,
+        ConnectorStatusEnum.Available,
+        evseId
+      )
+
+      return { status: UnlockStatusEnumType.Unlocked }
+    } catch (error) {
+      logger.error(
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestUnlockConnector: Error handling unlock connector request:`,
+        error
+      )
+
+      return {
+        status: UnlockStatusEnumType.UnlockFailed,
+        statusInfo: {
+          additionalInfo: 'Internal error occurred while processing unlock connector request',
+          reasonCode: ReasonCodeEnumType.InternalError,
+        },
+      }
+    }
+  }
+
   /**
    * Checks if a specific EVSE has any active transactions.
    * @param evse - The EVSE to check
@@ -1884,7 +2064,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     )
 
     try {
-      // Check if local authorization is disabled and remote authorization is also disabled
       const localAuthListEnabled = chargingStation.getLocalAuthListEnabled()
       const remoteAuthorizationEnabled = chargingStation.stationInfo?.remoteAuthorization ?? true
 
@@ -1895,7 +2074,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         return true
       }
 
-      // 1. Check local authorization list first (if enabled)
       if (localAuthListEnabled) {
         const isLocalAuthorized = this.isIdTokenLocalAuthorized(chargingStation, idToken.idToken)
         if (isLocalAuthorized) {
@@ -1909,19 +2087,14 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         )
       }
 
-      // 2. For OCPP 2.0, if we can't authorize locally and remote auth is enabled,
-      //    we should validate through TransactionEvent mechanism or return false
-      //    In OCPP 2.0, there's no explicit remote authorize - it's handled during transaction events
+      // In OCPP 2.0, remote authorization happens during TransactionEvent processing
       if (remoteAuthorizationEnabled) {
         logger.debug(
           `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: Remote authorization enabled but no explicit remote auth mechanism in OCPP 2.0 - deferring to transaction event validation`
         )
-        // In OCPP 2.0, remote authorization happens during TransactionEvent processing
-        // For now, we'll allow the transaction to proceed and let the CSMS validate during TransactionEvent
         return true
       }
 
-      // 3. If we reach here, authorization failed
       logger.warn(
         `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: IdToken ${idToken.idToken} authorization failed - not found in local list and remote auth not configured`
       )
@@ -1931,7 +2104,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: Error during authorization validation for ${idToken.idToken}:`,
         error
       )
-      // Fail securely - deny access on authorization errors
       return false
     }
   }
@@ -1991,20 +2163,16 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     evseId: number,
     hasActiveTransactions: boolean
   ): void {
-    // Send status notification for unavailable EVSE
     this.sendEvseStatusNotifications(
       chargingStation,
       evseId,
       OCPP20ConnectorStatusEnumType.Unavailable
     )
 
-    // Schedule the actual EVSE reset
     setImmediate(() => {
       logger.info(
         `${chargingStation.logPrefix()} ${moduleName}.scheduleEvseReset: Executing EVSE ${evseId.toString()} reset${hasActiveTransactions ? ' after transaction termination' : ''}`
       )
-      // Reset EVSE - this would typically involve resetting the EVSE hardware/software
-      // For now, we'll restore connectors to available status after a short delay
       setTimeout(() => {
         const evse = chargingStation.getEvseStatus(evseId)
         if (evse) {
@@ -2129,23 +2297,19 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     response: OCPP20GetBaseReportResponse
   ): Promise<void> {
     const { reportBase, requestId } = request
-    // Use cached report data if available (computed during GetBaseReport handling)
     const cached = this.reportDataCache.get(requestId)
     const reportData = cached ?? this.buildReportData(chargingStation, reportBase)
 
-    // Fragment report data if needed (OCPP2 spec recommends max 100 items per message)
     const maxItemsPerMessage = 100
     const chunks = []
     for (let i = 0; i < reportData.length; i += maxItemsPerMessage) {
       chunks.push(reportData.slice(i, i + maxItemsPerMessage))
     }
 
-    // Ensure we always send at least one message
     if (chunks.length === 0) {
       chunks.push(undefined) // undefined means reportData will be omitted from the request
     }
 
-    // Send fragmented NotifyReport messages
     for (let seqNo = 0; seqNo < chunks.length; seqNo++) {
       const isLastChunk = seqNo === chunks.length - 1
       const chunk = chunks[seqNo]
@@ -2155,7 +2319,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         requestId,
         seqNo,
         tbc: !isLastChunk,
-        // Only include reportData if chunk is defined and not empty
         ...(chunk !== undefined && chunk.length > 0 && { reportData: chunk }),
       }
 
@@ -2174,7 +2337,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
       `${chargingStation.logPrefix()} ${moduleName}.sendNotifyReportRequest: Completed NotifyReport for requestId ${requestId} with ${reportData.length} total items in ${chunks.length} message(s)`
     )
-    // Clear cache for requestId after successful completion
     this.reportDataCache.delete(requestId)
   }
 
@@ -2195,7 +2357,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
           logger.info(
             `${chargingStation.logPrefix()} ${moduleName}.terminateAllTransactions: Terminating transaction ${connector.transactionId.toString()} on connector ${connectorId.toString()}`
           )
-          // Use the proper OCPP 2.0 transaction termination method
           terminationPromises.push(
             OCPP20ServiceUtils.requestStopTransaction(chargingStation, connectorId, evseId).catch(
               (error: unknown) => {
@@ -2243,7 +2404,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         logger.info(
           `${chargingStation.logPrefix()} ${moduleName}.terminateEvseTransactions: Terminating transaction ${connector.transactionId.toString()} on connector ${connectorId.toString()}`
         )
-        // Use the proper OCPP 2.0 transaction termination method
         terminationPromises.push(
           OCPP20ServiceUtils.requestStopTransaction(chargingStation, connectorId, evseId).catch(
             (error: unknown) => {
@@ -2265,6 +2425,13 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     }
   }
 
+  private toHandler (
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    handler: (chargingStation: ChargingStation, commandPayload: any) => JsonType | Promise<JsonType>
+  ): IncomingRequestHandler {
+    return handler as IncomingRequestHandler
+  }
+
   private validateChargingProfile (
     chargingStation: ChargingStation,
     chargingProfile: OCPP20ChargingProfileType,
@@ -2274,7 +2441,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       `${chargingStation.logPrefix()} ${moduleName}.validateChargingProfile: Validating charging profile ${chargingProfile.id.toString()} for EVSE ${evseId.toString()}`
     )
 
-    // Validate stack level range (OCPP 2.0 spec: 0-9)
     if (chargingProfile.stackLevel < 0 || chargingProfile.stackLevel > 9) {
       logger.warn(
         `${chargingStation.logPrefix()} ${moduleName}.validateChargingProfile: Invalid stack level ${chargingProfile.stackLevel.toString()}, must be 0-9`
@@ -2282,7 +2448,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return false
     }
 
-    // Validate charging profile ID is positive
     if (chargingProfile.id <= 0) {
       logger.warn(
         `${chargingStation.logPrefix()} ${moduleName}.validateChargingProfile: Invalid charging profile ID ${chargingProfile.id.toString()}, must be positive`
@@ -2290,7 +2455,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return false
     }
 
-    // Validate EVSE compatibility
     if (!chargingStation.hasEvses && evseId > 0) {
       logger.warn(
         `${chargingStation.logPrefix()} ${moduleName}.validateChargingProfile: EVSE ${evseId.toString()} not supported by this charging station`
@@ -2305,7 +2469,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return false
     }
 
-    // Validate charging schedules array is not empty
     if (chargingProfile.chargingSchedule.length === 0) {
       logger.warn(
         `${chargingStation.logPrefix()} ${moduleName}.validateChargingProfile: Charging profile must contain at least one charging schedule`
@@ -2313,7 +2476,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return false
     }
 
-    // Time constraints validation
     const now = new Date()
     if (chargingProfile.validFrom && chargingProfile.validTo) {
       if (chargingProfile.validFrom >= chargingProfile.validTo) {
@@ -2331,7 +2493,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return false
     }
 
-    // Validate recurrency kind compatibility with profile kind
     if (
       chargingProfile.recurrencyKind &&
       chargingProfile.chargingProfileKind !== OCPP20ChargingProfileKindEnumType.Recurring
@@ -2352,7 +2513,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return false
     }
 
-    // Validate each charging schedule
     for (const [scheduleIndex, schedule] of chargingProfile.chargingSchedule.entries()) {
       if (
         !this.validateChargingSchedule(
@@ -2367,7 +2527,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       }
     }
 
-    // Profile purpose specific validations
     if (!this.validateChargingProfilePurpose(chargingStation, chargingProfile, evseId)) {
       return false
     }
@@ -2396,7 +2555,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
 
     switch (chargingProfile.chargingProfilePurpose) {
       case OCPP20ChargingProfilePurposeEnumType.ChargingStationExternalConstraints:
-        // ChargingStationExternalConstraints must apply to EVSE 0 (entire station)
         if (evseId !== 0) {
           logger.warn(
             `${chargingStation.logPrefix()} ${moduleName}.validateChargingProfilePurpose: ChargingStationExternalConstraints must apply to EVSE 0, got EVSE ${evseId.toString()}`
@@ -2406,7 +2564,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         break
 
       case OCPP20ChargingProfilePurposeEnumType.ChargingStationMaxProfile:
-        // ChargingStationMaxProfile must apply to EVSE 0 (entire station)
         if (evseId !== 0) {
           logger.warn(
             `${chargingStation.logPrefix()} ${moduleName}.validateChargingProfilePurpose: ChargingStationMaxProfile must apply to EVSE 0, got EVSE ${evseId.toString()}`
@@ -2416,12 +2573,9 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         break
 
       case OCPP20ChargingProfilePurposeEnumType.TxDefaultProfile:
-        // TxDefaultProfile can apply to EVSE 0 or specific EVSE
-        // No additional constraints beyond general EVSE validation
         break
 
       case OCPP20ChargingProfilePurposeEnumType.TxProfile:
-        // TxProfile must apply to a specific EVSE (not 0)
         if (evseId === 0) {
           logger.warn(
             `${chargingStation.logPrefix()} ${moduleName}.validateChargingProfilePurpose: TxProfile cannot apply to EVSE 0, must target specific EVSE`
@@ -2429,7 +2583,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
           return false
         }
 
-        // TxProfile should have a transactionId when used with active transaction
         if (!chargingProfile.transactionId) {
           logger.debug(
             `${chargingStation.logPrefix()} ${moduleName}.validateChargingProfilePurpose: TxProfile without transactionId - may be for future use`
@@ -2471,7 +2624,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       `${chargingStation.logPrefix()} ${moduleName}.validateChargingSchedule: Validating schedule ${scheduleIndex.toString()} (ID: ${schedule.id.toString()}) in profile ${chargingProfile.id.toString()}`
     )
 
-    // Validate schedule ID is positive
     if (schedule.id <= 0) {
       logger.warn(
         `${chargingStation.logPrefix()} ${moduleName}.validateChargingSchedule: Invalid schedule ID ${schedule.id.toString()}, must be positive`
@@ -2479,7 +2631,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return false
     }
 
-    // Validate charging schedule periods array is not empty
     if (schedule.chargingSchedulePeriod.length === 0) {
       logger.warn(
         `${chargingStation.logPrefix()} ${moduleName}.validateChargingSchedule: Schedule must contain at least one charging schedule period`
@@ -2487,7 +2638,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return false
     }
 
-    // Validate charging rate unit is valid (type system ensures it exists)
     if (!Object.values(OCPP20ChargingRateUnitEnumType).includes(schedule.chargingRateUnit)) {
       logger.warn(
         `${chargingStation.logPrefix()} ${moduleName}.validateChargingSchedule: Invalid charging rate unit: ${schedule.chargingRateUnit}`
@@ -2495,7 +2645,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return false
     }
 
-    // Validate duration constraints
     if (schedule.duration !== undefined && schedule.duration <= 0) {
       logger.warn(
         `${chargingStation.logPrefix()} ${moduleName}.validateChargingSchedule: Schedule duration must be positive if specified`
@@ -2503,7 +2652,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return false
     }
 
-    // Validate minimum charging rate if specified
     if (schedule.minChargingRate !== undefined && schedule.minChargingRate < 0) {
       logger.warn(
         `${chargingStation.logPrefix()} ${moduleName}.validateChargingSchedule: Minimum charging rate cannot be negative`
@@ -2511,7 +2659,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return false
     }
 
-    // Validate start schedule time constraints
     if (
       schedule.startSchedule &&
       chargingProfile.validFrom &&
@@ -2534,10 +2681,8 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       return false
     }
 
-    // Validate charging schedule periods
     let previousStartPeriod = -1
     for (const [periodIndex, period] of schedule.chargingSchedulePeriod.entries()) {
-      // Validate start period is non-negative and increasing
       if (period.startPeriod < 0) {
         logger.warn(
           `${chargingStation.logPrefix()} ${moduleName}.validateChargingSchedule: Period ${periodIndex.toString()} start time cannot be negative`
@@ -2553,7 +2698,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       }
       previousStartPeriod = period.startPeriod
 
-      // Validate charging limit is positive
       if (period.limit <= 0) {
         logger.warn(
           `${chargingStation.logPrefix()} ${moduleName}.validateChargingSchedule: Period ${periodIndex.toString()} charging limit must be positive`
@@ -2561,7 +2705,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         return false
       }
 
-      // Validate minimum charging rate constraint
       if (schedule.minChargingRate !== undefined && period.limit < schedule.minChargingRate) {
         logger.warn(
           `${chargingStation.logPrefix()} ${moduleName}.validateChargingSchedule: Period ${periodIndex.toString()} limit cannot be below minimum charging rate`
@@ -2569,7 +2712,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         return false
       }
 
-      // Validate number of phases constraints
       if (period.numberPhases !== undefined) {
         if (period.numberPhases < 1 || period.numberPhases > 3) {
           logger.warn(
@@ -2578,7 +2720,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
           return false
         }
 
-        // If phaseToUse is specified, validate it's within the number of phases
         if (
           period.phaseToUse !== undefined &&
           (period.phaseToUse < 1 || period.phaseToUse > period.numberPhases)
@@ -2618,3 +2759,68 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     return false
   }
 }
+
+/**
+ * OCPP 2.0+ Incoming Request Service - handles and processes all incoming requests
+ * from the Central System (CSMS) to the Charging Station using OCPP 2.0+ protocol.
+ *
+ * This service class is responsible for:
+ * - **Request Reception**: Receiving and routing OCPP 2.0+ incoming requests from CSMS
+ * - **Payload Validation**: Validating incoming request payloads against OCPP 2.0+ JSON schemas
+ * - **Request Processing**: Executing business logic for each OCPP 2.0+ request type
+ * - **Response Generation**: Creating and sending appropriate responses back to CSMS
+ * - **Enhanced Features**: Supporting advanced OCPP 2.0+ features like variable management
+ *
+ * Supported OCPP 2.0+ Incoming Request Types:
+ * - **Transaction Management**: RequestStartTransaction, RequestStopTransaction
+ * - **Configuration Management**: SetVariables, GetVariables, GetBaseReport
+ * - **Security Operations**: CertificatesSigned, SecurityEventNotification
+ * - **Charging Management**: SetChargingProfile, ClearChargingProfile, GetChargingProfiles
+ * - **Diagnostics**: TriggerMessage, GetLog, UpdateFirmware
+ * - **Display Management**: SetDisplayMessage, ClearDisplayMessage
+ * - **Customer Management**: ClearCache, SendLocalList
+ *
+ * Key OCPP 2.0+ Enhancements:
+ * - **Variable Model**: Advanced configuration through standardized variable system
+ * - **Enhanced Security**: Improved authentication and authorization mechanisms
+ * - **Rich Messaging**: Support for display messages and customer information
+ * - **Advanced Monitoring**: Comprehensive logging and diagnostic capabilities
+ * - **Flexible Charging**: Enhanced charging profile management and scheduling
+ *
+ * Architecture Pattern:
+ * This class extends OCPPIncomingRequestService and implements OCPP 2.0+-specific
+ * request handling logic. It integrates with the OCPP20VariableManager for advanced
+ * configuration management and maintains backward compatibility concepts while
+ * providing next-generation OCPP features.
+ *
+ * Validation Workflow:
+ * 1. Incoming request received and parsed
+ * 2. Payload validated against OCPP 2.0+ JSON schema
+ * 3. Request routed to appropriate handler method
+ * 4. Business logic executed with variable model integration
+ * 5. Response payload validated and sent back to CSMS
+ * @see {@link validatePayload} Request payload validation method
+ * @see {@link handleRequestStartTransaction} Example OCPP 2.0+ request handler
+ * @see {@link OCPP20VariableManager} Variable management integration
+ */
+
+/**
+ * Races a promise against a timeout, clearing the timer on settlement to avoid leaks.
+ * @param promise - The promise to race against the timeout
+ * @param ms - Timeout duration in milliseconds
+ * @param label - Descriptive label for the timeout error message
+ * @returns The resolved value of the original promise, or rejects with a timeout error
+ */
+function withTimeout<T> (promise: Promise<T>, ms: number, label: string): Promise<T> {
+  let timer: ReturnType<typeof setTimeout>
+  return Promise.race([
+    promise.finally(() => {
+      clearTimeout(timer)
+    }),
+    new Promise<never>((_resolve, reject) => {
+      timer = setTimeout(() => {
+        reject(new Error(`${label} timed out after ${ms.toString()}ms`))
+      }, ms)
+    }),
+  ])
+}
index 7f0515668de0a002df152f5d7b6d92a402b2df78..50a66ccae61b3e37262eeabe9abee501417d8b51 100644 (file)
@@ -302,12 +302,10 @@ export class OCPP20RequestService extends OCPPRequestService {
       throw new OCPPError(ErrorType.INTERNAL_ERROR, errorMsg, OCPP20RequestCommand.SIGN_CERTIFICATE)
     }
 
-    // Build request payload
     const requestPayload: OCPP20SignCertificateRequest = {
       csr,
     }
 
-    // Add certificate type if specified
     if (certificateType != null) {
       requestPayload.certificateType = certificateType
     }
index 36fe0e550dbdd154e22c5de8d6b03d8b3f27b60f..593f8905e4aa5bf096146c144e1d86058d2b28a2 100644 (file)
@@ -13,6 +13,7 @@ import {
   OCPP20OptionalVariableName,
   OCPP20RequestCommand,
   type OCPP20StatusNotificationResponse,
+  type OCPP20TransactionEventResponse,
   OCPPVersion,
   RegistrationStatusEnumType,
   type ResponseHandler,
@@ -90,6 +91,10 @@ export class OCPP20ResponseService extends OCPPResponseService {
         OCPP20RequestCommand.STATUS_NOTIFICATION,
         this.handleResponseStatusNotification.bind(this) as ResponseHandler,
       ],
+      [
+        OCPP20RequestCommand.TRANSACTION_EVENT,
+        this.handleResponseTransactionEvent.bind(this) as ResponseHandler,
+      ],
     ])
     this.payloadValidatorFunctions = OCPP20ServiceUtils.createPayloadValidatorMap(
       OCPP20ServiceUtils.createResponsePayloadConfigs(),
@@ -255,6 +260,37 @@ 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
+  private handleResponseTransactionEvent (
+    chargingStation: ChargingStation,
+    payload: OCPP20TransactionEventResponse
+  ): void {
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: TransactionEvent response received`
+    )
+    if (payload.totalCost != null) {
+      logger.info(
+        `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Total cost: ${payload.totalCost.toString()}`
+      )
+    }
+    if (payload.chargingPriority != null) {
+      logger.info(
+        `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Charging priority: ${payload.chargingPriority.toString()}`
+      )
+    }
+    if (payload.idTokenInfo != null) {
+      logger.info(
+        `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: IdToken info status: ${payload.idTokenInfo.status}`
+      )
+    }
+    if (payload.updatedPersonalMessage != null) {
+      logger.info(
+        `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Updated personal message format: ${payload.updatedPersonalMessage.format}, content: ${payload.updatedPersonalMessage.content}`
+      )
+    }
+  }
+
   /**
    * Validates incoming OCPP 2.0 response payload against JSON schema
    * @param chargingStation - The charging station instance receiving the response
index 2ea4623e6ba2b19254b688e43fda98a486ee2443..b9da34f23597691612699f006d019580b9a4ccf7 100644 (file)
@@ -16,7 +16,9 @@ import {
   type OCPP20TransactionEventResponse,
   OCPP20TriggerReasonEnumType,
   OCPPVersion,
+  type UUIDv4,
 } from '../../../types/index.js'
+import { OCPP20RequiredVariableName } from '../../../types/index.js'
 import {
   OCPP20MeasurandEnumType,
   type OCPP20MeterValue,
@@ -29,7 +31,8 @@ import {
   type OCPP20TransactionEventOptions,
   type OCPP20TransactionType,
 } from '../../../types/ocpp/2.0/Transaction.js'
-import { logger, validateIdentifierString } from '../../../utils/index.js'
+import { convertToIntOrNaN, logger, validateIdentifierString } from '../../../utils/index.js'
+import { getConfigurationKey } from '../../ConfigurationKeyUtils.js'
 import { OCPPServiceUtils, sendAndSetConnectorStatus } from '../OCPPServiceUtils.js'
 import { OCPP20Constants } from './OCPP20Constants.js'
 
@@ -84,7 +87,6 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
     transactionId: string,
     options?: OCPP20TransactionEventOptions
   ): OCPP20TransactionEventRequest
-  // Implementation with union type + type guard
   public static buildTransactionEvent (
     chargingStation: ChargingStation,
     eventType: OCPP20TransactionEventEnumType,
@@ -93,7 +95,6 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
     transactionId: string,
     options: OCPP20TransactionEventOptions = {}
   ): OCPP20TransactionEventRequest {
-    // Type guard: distinguish between context object and direct trigger reason
     const isContext = typeof triggerReasonOrContext === 'object'
     const triggerReason = isContext
       ? this.selectTriggerReason(eventType, triggerReasonOrContext)
@@ -108,7 +109,6 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
       throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg)
     }
 
-    // Get or validate EVSE ID
     const evseId = options.evseId ?? chargingStation.getEvseIdByConnectorId(connectorId)
     if (evseId == null) {
       const errorMsg = `Cannot find EVSE ID for connector ${connectorId.toString()}`
@@ -118,7 +118,6 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
       throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg)
     }
 
-    // Get connector status and manage sequence number
     const connectorStatus = chargingStation.getConnectorStatus(connectorId)
     if (connectorStatus == null) {
       const errorMsg = `Cannot find connector status for connector ${connectorId.toString()}`
@@ -128,13 +127,10 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
       throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg)
     }
 
-    // Per-EVSE sequence number management (OCPP 2.0.1 Section 1.3.2.1)
-    // Initialize sequence number to 0 for new transactions, or increment for existing
+    // Per-EVSE sequence number management (OCPP 2.0.1 §1.3.2.1)
     if (connectorStatus.transactionSeqNo == null) {
-      // First TransactionEvent for this EVSE/connector - start at 0
       connectorStatus.transactionSeqNo = 0
     } else {
-      // Increment for subsequent TransactionEvents
       connectorStatus.transactionSeqNo = connectorStatus.transactionSeqNo + 1
     }
 
@@ -148,12 +144,10 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
       connectorStatus.transactionEvseSent = true
     }
 
-    // Build transaction info object
     const transactionInfo: OCPP20TransactionType = {
-      transactionId,
+      transactionId: transactionId as UUIDv4,
     }
 
-    // Add optional transaction info fields
     if (options.chargingState !== undefined) {
       transactionInfo.chargingState = options.chargingState
     }
@@ -164,7 +158,6 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
       transactionInfo.remoteStartId = options.remoteStartId
     }
 
-    // Build the complete TransactionEvent request
     const transactionEventRequest: OCPP20TransactionEventRequest = {
       eventType,
       seqNo: connectorStatus.transactionSeqNo,
@@ -217,18 +210,34 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
     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'),
@@ -245,6 +254,14 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
       OCPP20IncomingRequestCommand.SET_VARIABLES,
       OCPP20ServiceUtils.PayloadValidatorConfig('SetVariablesRequest.json'),
     ],
+    [
+      OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+      OCPP20ServiceUtils.PayloadValidatorConfig('TriggerMessageRequest.json'),
+    ],
+    [
+      OCPP20IncomingRequestCommand.UNLOCK_CONNECTOR,
+      OCPP20ServiceUtils.PayloadValidatorConfig('UnlockConnectorRequest.json'),
+    ],
   ]
 
   /**
@@ -269,14 +286,34 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
     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'),
@@ -285,6 +322,22 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
       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'),
+    ],
   ]
 
   /**
@@ -484,6 +537,43 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
     )
   }
 
+  /**
+   * Read ItemsPerMessage and BytesPerMessage configuration limits
+   * Extracts configuration-reading logic shared between handleRequestGetVariables
+   * and handleRequestSetVariables to eliminate DRY violations.
+   * @param chargingStation - The charging station instance
+   * @returns Object with itemsLimit and bytesLimit (both fallback to 0 if not configured or invalid)
+   */
+  public static readMessageLimits (chargingStation: ChargingStation): {
+    bytesLimit: number
+    itemsLimit: number
+  } {
+    let itemsLimit = 0
+    let bytesLimit = 0
+    try {
+      const itemsCfg = getConfigurationKey(
+        chargingStation,
+        OCPP20RequiredVariableName.ItemsPerMessage
+      )?.value
+      const bytesCfg = getConfigurationKey(
+        chargingStation,
+        OCPP20RequiredVariableName.BytesPerMessage
+      )?.value
+      if (itemsCfg && /^\d+$/.test(itemsCfg)) {
+        itemsLimit = convertToIntOrNaN(itemsCfg)
+      }
+      if (bytesCfg && /^\d+$/.test(bytesCfg)) {
+        bytesLimit = convertToIntOrNaN(bytesCfg)
+      }
+    } catch (error) {
+      logger.debug(
+        `${chargingStation.logPrefix()} readMessageLimits: error reading message limits:`,
+        error
+      )
+    }
+    return { bytesLimit, itemsLimit }
+  }
+
   public static async requestStopTransaction (
     chargingStation: ChargingStation,
     connectorId: number,
@@ -491,7 +581,6 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
   ): Promise<GenericResponse> {
     const connectorStatus = chargingStation.getConnectorStatus(connectorId)
     if (connectorStatus?.transactionStarted && connectorStatus.transactionId != null) {
-      // OCPP 2.0 validation: transactionId should be a valid UUID format
       let transactionId: string
       if (typeof connectorStatus.transactionId === 'string') {
         transactionId = connectorStatus.transactionId
@@ -519,7 +608,7 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
 
       connectorStatus.transactionSeqNo = (connectorStatus.transactionSeqNo ?? 0) + 1
 
-      // FR: F03.FR.09 - Build final meter values for TransactionEvent(Ended)
+      // F03.FR.04: Build final meter values for TransactionEvent(Ended)
       const finalMeterValues: OCPP20MeterValue[] = []
       const energyValue = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
       if (energyValue >= 0) {
@@ -544,12 +633,12 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
         timestamp: new Date(),
         transactionInfo: {
           stoppedReason: OCPP20ReasonEnumType.Remote,
-          transactionId,
+          transactionId: transactionId as UUIDv4,
         },
         triggerReason: OCPP20TriggerReasonEnumType.RemoteStop,
       }
 
-      // FR: F03.FR.09 - Include final meter values in TransactionEvent(Ended)
+      // F03.FR.04: Include final meter values in TransactionEvent(Ended)
       if (finalMeterValues.length > 0) {
         transactionEventRequest.meterValue = finalMeterValues
       }
@@ -833,7 +922,6 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
         return { idTokenInfo: undefined }
       }
 
-      // Send the request to CSMS
       logger.debug(
         `${chargingStation.logPrefix()} ${moduleName}.sendTransactionEvent: Sending TransactionEvent for trigger ${triggerReason}`
       )
index 327168814ce8029a5d30717b19e3e77bb8e85b83..b11338680d5bb9dac9d688cef32da73a53288215 100644 (file)
@@ -18,7 +18,6 @@ import {
   SetVariableStatusEnumType,
   type VariableType,
 } from '../../../types/index.js'
-import { StandardParametersKey } from '../../../types/ocpp/Configuration.js'
 import { Constants, convertToIntOrNaN, logger } from '../../../utils/index.js'
 import { type ChargingStation } from '../../ChargingStation.js'
 import {
@@ -56,6 +55,10 @@ const computeConfigurationKeyName = (variableMetadata: VariableMetadata): string
 export class OCPP20VariableManager {
   private static instance: null | OCPP20VariableManager = null
 
+  readonly #validComponentNames = new Set<string>(
+    Object.keys(VARIABLE_REGISTRY).map(k => k.split('::')[0])
+  )
+
   private readonly invalidVariables = new Set<string>() // composite key (lower case)
   private readonly maxSetOverrides = new Map<string, string>() // composite key (lower case)
   private readonly minSetOverrides = new Map<string, string>() // composite key (lower case)
@@ -147,10 +150,7 @@ export class OCPP20VariableManager {
       }
       // Instance-scoped persistent variables are also auto-created when defaultValue is defined
       const configurationKeyName = computeConfigurationKeyName(variableMetadata)
-      const configurationKey = getConfigurationKey(
-        chargingStation,
-        configurationKeyName as unknown as StandardParametersKey
-      )
+      const configurationKey = getConfigurationKey(chargingStation, configurationKeyName)
       const variableKey = buildCaseInsensitiveCompositeKey(
         variableMetadata.component,
         variableMetadata.instance,
@@ -173,18 +173,13 @@ export class OCPP20VariableManager {
         }
         const defaultValue = variableMetadata.defaultValue
         if (defaultValue != null) {
-          addConfigurationKey(
-            chargingStation,
-            configurationKeyName as unknown as StandardParametersKey,
-            defaultValue,
-            undefined,
-            { overwrite: false }
-          )
+          addConfigurationKey(chargingStation, configurationKeyName, defaultValue, undefined, {
+            overwrite: false,
+          })
           logger.info(
             `${chargingStation.logPrefix()} Added missing configuration key for variable '${configurationKeyName}' with default '${defaultValue}'`
           )
         } else {
-          // Mark invalid
           this.invalidVariables.add(variableKey)
           logger.error(
             `${chargingStation.logPrefix()} Missing configuration key mapping and no default for variable '${configurationKeyName}'`
@@ -269,7 +264,6 @@ export class OCPP20VariableManager {
       )
     }
 
-    // Handle MinSet / MaxSet attribute retrieval
     if (resolvedAttributeType === AttributeEnumType.MinSet) {
       if (variableMetadata.min === undefined && this.minSetOverrides.get(variableKey) == null) {
         return this.rejectGet(
@@ -356,15 +350,12 @@ export class OCPP20VariableManager {
     let valueSize: string | undefined
     let reportingValueSize: string | undefined
     if (!this.invalidVariables.has(valueSizeKey)) {
-      valueSize = getConfigurationKey(
-        chargingStation,
-        OCPP20RequiredVariableName.ValueSize as unknown as StandardParametersKey
-      )?.value
+      valueSize = getConfigurationKey(chargingStation, OCPP20RequiredVariableName.ValueSize)?.value
     }
     if (!this.invalidVariables.has(reportingValueSizeKey)) {
       reportingValueSize = getConfigurationKey(
         chargingStation,
-        OCPP20RequiredVariableName.ReportingValueSize as unknown as StandardParametersKey
+        OCPP20RequiredVariableName.ReportingValueSize
       )?.value
     }
     // Apply ValueSize first then ReportingValueSize
@@ -389,17 +380,7 @@ export class OCPP20VariableManager {
   }
 
   private isComponentValid (_chargingStation: ChargingStation, component: ComponentType): boolean {
-    const supported = new Set<string>([
-      OCPP20ComponentName.AuthCtrlr as string,
-      OCPP20ComponentName.ChargingStation as string,
-      OCPP20ComponentName.ClockCtrlr as string,
-      OCPP20ComponentName.DeviceDataCtrlr as string,
-      OCPP20ComponentName.OCPPCommCtrlr as string,
-      OCPP20ComponentName.SampledDataCtrlr as string,
-      OCPP20ComponentName.SecurityCtrlr as string,
-      OCPP20ComponentName.TxCtrlr as string,
-    ])
-    return supported.has(component.name)
+    return this.#validComponentNames.has(component.name)
   }
 
   private isVariableSupported (component: ComponentType, variable: VariableType): boolean {
@@ -476,25 +457,19 @@ export class OCPP20VariableManager {
       variableMetadata.mutability !== MutabilityEnumType.WriteOnly
     ) {
       const configurationKeyName = computeConfigurationKeyName(variableMetadata)
-      let cfg = getConfigurationKey(
-        chargingStation,
-        configurationKeyName as unknown as StandardParametersKey
-      )
+      let cfg = getConfigurationKey(chargingStation, configurationKeyName)
 
       if (cfg == null) {
         addConfigurationKey(
           chargingStation,
-          configurationKeyName as unknown as StandardParametersKey,
+          configurationKeyName,
           value, // Use the resolved default value
           undefined,
           {
             overwrite: false,
           }
         )
-        cfg = getConfigurationKey(
-          chargingStation,
-          configurationKeyName as unknown as StandardParametersKey
-        )
+        cfg = getConfigurationKey(chargingStation, configurationKeyName)
       }
 
       if (cfg?.value) {
@@ -607,7 +582,6 @@ export class OCPP20VariableManager {
       resolvedAttributeType === AttributeEnumType.MinSet ||
       resolvedAttributeType === AttributeEnumType.MaxSet
     ) {
-      // Only meaningful for integer data type
       if (variableMetadata.dataType !== DataEnumType.integer) {
         return this.rejectSet(
           variable,
@@ -709,7 +683,6 @@ export class OCPP20VariableManager {
       }
     }
 
-    // Actual attribute setting logic
     if (variableMetadata.mutability === MutabilityEnumType.ReadOnly) {
       return this.rejectSet(
         variable,
@@ -745,13 +718,13 @@ export class OCPP20VariableManager {
       if (!this.invalidVariables.has(configurationValueSizeKey)) {
         configurationValueSizeRaw = getConfigurationKey(
           chargingStation,
-          OCPP20RequiredVariableName.ConfigurationValueSize as unknown as StandardParametersKey
+          OCPP20RequiredVariableName.ConfigurationValueSize
         )?.value
       }
       if (!this.invalidVariables.has(valueSizeKey)) {
         valueSizeRaw = getConfigurationKey(
           chargingStation,
-          OCPP20RequiredVariableName.ValueSize as unknown as StandardParametersKey
+          OCPP20RequiredVariableName.ValueSize
         )?.value
       }
       const cfgLimit = convertToIntOrNaN(configurationValueSizeRaw ?? '')
@@ -848,46 +821,23 @@ export class OCPP20VariableManager {
 
     let rebootRequired = false
     const configurationKeyName = computeConfigurationKeyName(variableMetadata)
-    const previousValue = getConfigurationKey(
-      chargingStation,
-      configurationKeyName as unknown as StandardParametersKey
-    )?.value
+    const previousValue = getConfigurationKey(chargingStation, configurationKeyName)?.value
 
     if (
       variableMetadata.persistence === PersistenceEnumType.Persistent &&
       variableMetadata.mutability !== MutabilityEnumType.WriteOnly
     ) {
-      let configKey = getConfigurationKey(
-        chargingStation,
-        configurationKeyName as unknown as StandardParametersKey
-      )
+      const configKey = getConfigurationKey(chargingStation, configurationKeyName)
       if (configKey == null) {
-        addConfigurationKey(
-          chargingStation,
-          configurationKeyName as unknown as StandardParametersKey,
-          attributeValue,
-          undefined,
-          {
-            overwrite: false,
-          }
-        )
-        configKey = getConfigurationKey(
-          chargingStation,
-          configurationKeyName as unknown as StandardParametersKey
-        )
+        addConfigurationKey(chargingStation, configurationKeyName, attributeValue, undefined, {
+          overwrite: false,
+        })
       } else if (configKey.value !== attributeValue) {
-        setConfigurationKeyValue(
-          chargingStation,
-          configurationKeyName as unknown as StandardParametersKey,
-          attributeValue
-        )
+        setConfigurationKeyValue(chargingStation, configurationKeyName, attributeValue)
       }
       rebootRequired =
         (variableMetadata.rebootRequired === true ||
-          getConfigurationKey(
-            chargingStation,
-            configurationKeyName as unknown as StandardParametersKey
-          )?.reboot === true) &&
+          getConfigurationKey(chargingStation, configurationKeyName)?.reboot === true) &&
         previousValue !== attributeValue
     }
     // Heartbeat & WS ping interval dynamic restarts
index 1bbb20eb538ab57a960e71609d3073b416d64be6..81dd5f0c0c21f0872c23ff4e7f7e368419974d61 100644 (file)
@@ -187,7 +187,7 @@ export function createTestableRequestService<T extends JsonType = JsonType> (
   // Create typed wrapper for the mock
   const sendMessageMock: SendMessageMock = {
     fn: mockFn as unknown as SendMessageFn,
-    mock: mockFn.mock as SendMessageMock['mock'],
+    mock: mockFn.mock as unknown as SendMessageMock['mock'],
   }
 
   return {
index 403fa32813ea901e4cdfaf6773221eb6b80a10e5..6fd50748e88c524307cd1ff836c8eafc41081e25 100644 (file)
@@ -37,8 +37,12 @@ import type {
   OCPP20ResetResponse,
   OCPP20SetVariablesRequest,
   OCPP20SetVariablesResponse,
+  OCPP20TriggerMessageRequest,
+  OCPP20TriggerMessageResponse,
+  OCPP20UnlockConnectorRequest,
+  OCPP20UnlockConnectorResponse,
   ReportBaseEnumType,
-  type ReportDataType,
+  ReportDataType,
 } from '../../../../types/index.js'
 import type { ChargingStation } from '../../../index.js'
 import type { OCPP20IncomingRequestService } from '../OCPP20IncomingRequestService.js'
@@ -152,6 +156,16 @@ export interface TestableOCPP20IncomingRequestService {
     chargingStation: ChargingStation,
     commandPayload: OCPP20RequestStopTransactionRequest
   ) => Promise<OCPP20RequestStopTransactionResponse>
+
+  handleRequestTriggerMessage: (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20TriggerMessageRequest
+  ) => OCPP20TriggerMessageResponse
+
+  handleRequestUnlockConnector: (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20UnlockConnectorRequest
+  ) => Promise<OCPP20UnlockConnectorResponse>
 }
 
 /**
@@ -190,6 +204,8 @@ export function createTestableIncomingRequestService (
     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),
   }
 }
 
index 47f67f4a6b664350c7bab4e0a82b656ef1a8992c..ffaf5107de9797b585215a861d048a00adb79afd 100644 (file)
@@ -35,6 +35,7 @@ import {
   MeterValuePhase,
   MeterValueUnit,
   type OCPP16ChargePointStatus,
+  type OCPP16MeterValue,
   type OCPP16SampledValue,
   type OCPP16StatusNotificationRequest,
   type OCPP20ConnectorStatusEnumType,
@@ -153,11 +154,11 @@ export const isIdTagAuthorizedUnified = async (
   connectorId: number,
   idTag: string
 ): Promise<boolean> => {
+  const stationOcppVersion = chargingStation.stationInfo?.ocppVersion
   // OCPP 2.0+ always uses unified auth system
   // OCPP 1.6 can optionally use unified or legacy system
   const shouldUseUnified =
-    chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_20 ||
-    chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_201
+    stationOcppVersion === OCPPVersion.VERSION_20 || stationOcppVersion === OCPPVersion.VERSION_201
 
   if (shouldUseUnified) {
     try {
@@ -182,8 +183,7 @@ export const isIdTagAuthorizedUnified = async (
         connectorId,
         context: AuthContext.TRANSACTION_START,
         identifier: {
-          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-          ocppVersion: chargingStation.stationInfo.ocppVersion!,
+          ocppVersion: stationOcppVersion,
           type: IdentifierType.ID_TAG,
           value: idTag,
         },
@@ -524,33 +524,48 @@ const buildVoltageMeasurandValue = (
   }
 }
 
-const addMainVoltageToMeterValue = (
+const addMainVoltageToMeterValue = <TSampledValue extends OCPP16SampledValue | OCPP20SampledValue>(
   chargingStation: ChargingStation,
-  meterValue: MeterValue,
-  voltageData: { template: SampledValueTemplate; value: number }
+  meterValue: { sampledValue: TSampledValue[] },
+  voltageData: { template: SampledValueTemplate; value: number },
+  buildVersionedSampledValue: (
+    sampledValueTemplate: SampledValueTemplate,
+    value: number,
+    context?: MeterValueContext,
+    phase?: MeterValuePhase
+  ) => TSampledValue
 ): void => {
+  const stationInfo = chargingStation.stationInfo
+  if (stationInfo == null) {
+    return
+  }
   if (
     chargingStation.getNumberOfPhases() !== 3 ||
-    (chargingStation.getNumberOfPhases() === 3 &&
-      chargingStation.stationInfo?.mainVoltageMeterValues === true)
+    (chargingStation.getNumberOfPhases() === 3 && stationInfo.mainVoltageMeterValues === true)
   ) {
     meterValue.sampledValue.push(
-      buildSampledValue(
-        chargingStation.stationInfo.ocppVersion,
-        voltageData.template,
-        voltageData.value
-      )
+      buildVersionedSampledValue(voltageData.template, voltageData.value)
     )
   }
 }
 
-const addPhaseVoltageToMeterValue = (
+const addPhaseVoltageToMeterValue = <TSampledValue extends OCPP16SampledValue | OCPP20SampledValue>(
   chargingStation: ChargingStation,
   connectorId: number,
-  meterValue: MeterValue,
+  meterValue: { sampledValue: TSampledValue[] },
   mainVoltageData: { template: SampledValueTemplate; value: number },
-  phase: number
+  phase: number,
+  buildVersionedSampledValue: (
+    sampledValueTemplate: SampledValueTemplate,
+    value: number,
+    context?: MeterValueContext,
+    phase?: MeterValuePhase
+  ) => TSampledValue
 ): void => {
+  const stationInfo = chargingStation.stationInfo
+  if (stationInfo == null) {
+    return
+  }
   const phaseLineToNeutralValue = `L${phase.toString()}-N` as MeterValuePhase
   const voltagePhaseLineToNeutralSampledValueTemplate = getSampledValueTemplate(
     chargingStation,
@@ -574,8 +589,7 @@ const addPhaseVoltageToMeterValue = (
     )
   }
   meterValue.sampledValue.push(
-    buildSampledValue(
-      chargingStation.stationInfo.ocppVersion,
+    buildVersionedSampledValue(
       voltagePhaseLineToNeutralSampledValueTemplate ?? mainVoltageData.template,
       voltagePhaseLineToNeutralMeasurandValue ?? mainVoltageData.value,
       undefined,
@@ -584,54 +598,63 @@ const addPhaseVoltageToMeterValue = (
   )
 }
 
-const addLineToLineVoltageToMeterValue = (
-  chargingStation: ChargingStation,
-  connectorId: number,
-  meterValue: MeterValue,
-  mainVoltageData: { template: SampledValueTemplate; value: number },
-  phase: number
-): void => {
-  if (chargingStation.stationInfo.phaseLineToLineVoltageMeterValues === true) {
-    const phaseLineToLineValue = `L${phase.toString()}-L${
-      (phase + 1) % chargingStation.getNumberOfPhases() !== 0
-        ? ((phase + 1) % chargingStation.getNumberOfPhases()).toString()
-        : chargingStation.getNumberOfPhases().toString()
-    }` as MeterValuePhase
-    const voltagePhaseLineToLineValueRounded = roundTo(
-      Math.sqrt(chargingStation.getNumberOfPhases()) * chargingStation.getVoltageOut(),
-      2
-    )
-    const voltagePhaseLineToLineSampledValueTemplate = getSampledValueTemplate(
-      chargingStation,
-      connectorId,
-      MeterValueMeasurand.VOLTAGE,
-      phaseLineToLineValue
+const addLineToLineVoltageToMeterValue = <
+  TSampledValue extends OCPP16SampledValue | OCPP20SampledValue
+>(
+    chargingStation: ChargingStation,
+    connectorId: number,
+    meterValue: { sampledValue: TSampledValue[] },
+    mainVoltageData: { template: SampledValueTemplate; value: number },
+    phase: number,
+    buildVersionedSampledValue: (
+      sampledValueTemplate: SampledValueTemplate,
+      value: number,
+      context?: MeterValueContext,
+      phase?: MeterValuePhase
+    ) => TSampledValue
+  ): void => {
+  const stationInfo = chargingStation.stationInfo
+  if (stationInfo?.phaseLineToLineVoltageMeterValues !== true) {
+    return
+  }
+  const phaseLineToLineValue = `L${phase.toString()}-L${
+    (phase + 1) % chargingStation.getNumberOfPhases() !== 0
+      ? ((phase + 1) % chargingStation.getNumberOfPhases()).toString()
+      : chargingStation.getNumberOfPhases().toString()
+  }` as MeterValuePhase
+  const voltagePhaseLineToLineValueRounded = roundTo(
+    Math.sqrt(chargingStation.getNumberOfPhases()) * chargingStation.getVoltageOut(),
+    2
+  )
+  const voltagePhaseLineToLineSampledValueTemplate = getSampledValueTemplate(
+    chargingStation,
+    connectorId,
+    MeterValueMeasurand.VOLTAGE,
+    phaseLineToLineValue
+  )
+  let voltagePhaseLineToLineMeasurandValue: number | undefined
+  if (voltagePhaseLineToLineSampledValueTemplate != null) {
+    const voltagePhaseLineToLineSampledValueTemplateValue = isNotEmptyString(
+      voltagePhaseLineToLineSampledValueTemplate.value
     )
-    let voltagePhaseLineToLineMeasurandValue: number | undefined
-    if (voltagePhaseLineToLineSampledValueTemplate != null) {
-      const voltagePhaseLineToLineSampledValueTemplateValue = isNotEmptyString(
-        voltagePhaseLineToLineSampledValueTemplate.value
-      )
-        ? Number.parseInt(voltagePhaseLineToLineSampledValueTemplate.value)
-        : voltagePhaseLineToLineValueRounded
-      const fluctuationPhaseLineToLinePercent =
-        voltagePhaseLineToLineSampledValueTemplate.fluctuationPercent ??
-        Constants.DEFAULT_FLUCTUATION_PERCENT
-      voltagePhaseLineToLineMeasurandValue = getRandomFloatFluctuatedRounded(
-        voltagePhaseLineToLineSampledValueTemplateValue,
-        fluctuationPhaseLineToLinePercent
-      )
-    }
-    meterValue.sampledValue.push(
-      buildSampledValue(
-        chargingStation.stationInfo.ocppVersion,
-        voltagePhaseLineToLineSampledValueTemplate ?? mainVoltageData.template,
-        voltagePhaseLineToLineMeasurandValue ?? voltagePhaseLineToLineValueRounded,
-        undefined,
-        phaseLineToLineValue
-      )
+      ? Number.parseInt(voltagePhaseLineToLineSampledValueTemplate.value)
+      : voltagePhaseLineToLineValueRounded
+    const fluctuationPhaseLineToLinePercent =
+      voltagePhaseLineToLineSampledValueTemplate.fluctuationPercent ??
+      Constants.DEFAULT_FLUCTUATION_PERCENT
+    voltagePhaseLineToLineMeasurandValue = getRandomFloatFluctuatedRounded(
+      voltagePhaseLineToLineSampledValueTemplateValue,
+      fluctuationPhaseLineToLinePercent
     )
   }
+  meterValue.sampledValue.push(
+    buildVersionedSampledValue(
+      voltagePhaseLineToLineSampledValueTemplate ?? mainVoltageData.template,
+      voltagePhaseLineToLineMeasurandValue ?? voltagePhaseLineToLineValueRounded,
+      undefined,
+      phaseLineToLineValue
+    )
+  )
 }
 
 const buildEnergyMeasurandValue = (
@@ -1184,19 +1207,25 @@ export const buildMeterValue = (
   debug = false
 ): MeterValue => {
   const connector = chargingStation.getConnectorStatus(connectorId)
-  let meterValue: MeterValue
 
   switch (chargingStation.stationInfo?.ocppVersion) {
     case OCPPVersion.VERSION_16: {
-      meterValue = {
+      const meterValue: OCPP16MeterValue = {
         sampledValue: [],
         timestamp: new Date(),
       }
+      const buildVersionedSampledValue = (
+        sampledValueTemplate: SampledValueTemplate,
+        value: number,
+        context?: MeterValueContext,
+        phase?: MeterValuePhase
+      ): OCPP16SampledValue => {
+        return buildSampledValueForOCPP16(sampledValueTemplate, value, context, phase)
+      }
       // SoC measurand
       const socMeasurand = buildSocMeasurandValue(chargingStation, connectorId)
       if (socMeasurand != null) {
-        const socSampledValue = buildSampledValue(
-          chargingStation.stationInfo.ocppVersion,
+        const socSampledValue = buildVersionedSampledValue(
           socMeasurand.template,
           socMeasurand.value
         )
@@ -1213,7 +1242,12 @@ export const buildMeterValue = (
       // Voltage measurand
       const voltageMeasurand = buildVoltageMeasurandValue(chargingStation, connectorId)
       if (voltageMeasurand != null) {
-        addMainVoltageToMeterValue(chargingStation, meterValue, voltageMeasurand)
+        addMainVoltageToMeterValue(
+          chargingStation,
+          meterValue,
+          voltageMeasurand,
+          buildVersionedSampledValue
+        )
         for (
           let phase = 1;
           chargingStation.getNumberOfPhases() === 3 && phase <= chargingStation.getNumberOfPhases();
@@ -1224,14 +1258,16 @@ export const buildMeterValue = (
             connectorId,
             meterValue,
             voltageMeasurand,
-            phase
+            phase,
+            buildVersionedSampledValue
           )
           addLineToLineVoltageToMeterValue(
             chargingStation,
             connectorId,
             meterValue,
             voltageMeasurand,
-            phase
+            phase,
+            buildVersionedSampledValue
           )
         }
       }
@@ -1245,11 +1281,7 @@ export const buildMeterValue = (
         const connectorMinimumPower = Math.round(powerMeasurand.template.minimumValue ?? 0)
 
         meterValue.sampledValue.push(
-          buildSampledValue(
-            chargingStation.stationInfo.ocppVersion,
-            powerMeasurand.template,
-            powerMeasurand.values.allPhases
-          )
+          buildVersionedSampledValue(powerMeasurand.template, powerMeasurand.values.allPhases)
         )
         const sampledValuesIndex = meterValue.sampledValue.length - 1
         validatePowerMeasurandValue(
@@ -1278,13 +1310,7 @@ export const buildMeterValue = (
               const phasePowerValue =
                 powerMeasurand.values[`L${phase.toString()}` as keyof MeasurandValues]
               meterValue.sampledValue.push(
-                buildSampledValue(
-                  chargingStation.stationInfo.ocppVersion,
-                  phaseTemplate,
-                  phasePowerValue,
-                  undefined,
-                  phaseValue
-                )
+                buildVersionedSampledValue(phaseTemplate, phasePowerValue, undefined, phaseValue)
               )
               const sampledValuesPerPhaseIndex = meterValue.sampledValue.length - 1
               validatePowerMeasurandValue(
@@ -1319,11 +1345,7 @@ export const buildMeterValue = (
         const connectorMinimumAmperage = currentMeasurand.template.minimumValue ?? 0
 
         meterValue.sampledValue.push(
-          buildSampledValue(
-            chargingStation.stationInfo.ocppVersion,
-            currentMeasurand.template,
-            currentMeasurand.values.allPhases
-          )
+          buildVersionedSampledValue(currentMeasurand.template, currentMeasurand.values.allPhases)
         )
         const sampledValuesIndex = meterValue.sampledValue.length - 1
         validateCurrentMeasurandValue(
@@ -1342,8 +1364,7 @@ export const buildMeterValue = (
         ) {
           const phaseValue = `L${phase.toString()}` as MeterValuePhase
           meterValue.sampledValue.push(
-            buildSampledValue(
-              chargingStation.stationInfo.ocppVersion,
+            buildVersionedSampledValue(
               currentMeasurand.perPhaseTemplates[
                 phaseValue as keyof MeasurandPerPhaseSampledValueTemplates
               ] ?? currentMeasurand.template,
@@ -1370,8 +1391,7 @@ export const buildMeterValue = (
         updateConnectorEnergyValues(connector, energyMeasurand.value)
         const unitDivider =
           energyMeasurand.template.unit === MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1
-        const energySampledValue = buildSampledValue(
-          chargingStation.stationInfo.ocppVersion,
+        const energySampledValue = buildVersionedSampledValue(
           energyMeasurand.template,
           roundTo(
             chargingStation.getEnergyActiveImportRegisterByTransactionId(transactionId) /
@@ -1406,11 +1426,18 @@ export const buildMeterValue = (
         sampledValue: [],
         timestamp: new Date(),
       }
+      const buildVersionedSampledValue = (
+        sampledValueTemplate: SampledValueTemplate,
+        value: number,
+        context?: MeterValueContext,
+        phase?: MeterValuePhase
+      ): OCPP20SampledValue => {
+        return buildSampledValueForOCPP20(sampledValueTemplate, value, context, phase)
+      }
       // SoC measurand
       const socMeasurand = buildSocMeasurandValue(chargingStation, connectorId)
       if (socMeasurand != null) {
-        const socSampledValue = buildSampledValue(
-          chargingStation.stationInfo.ocppVersion,
+        const socSampledValue = buildVersionedSampledValue(
           socMeasurand.template,
           socMeasurand.value
         )
@@ -1427,7 +1454,12 @@ export const buildMeterValue = (
       // Voltage measurand
       const voltageMeasurand = buildVoltageMeasurandValue(chargingStation, connectorId)
       if (voltageMeasurand != null) {
-        addMainVoltageToMeterValue(chargingStation, meterValue, voltageMeasurand)
+        addMainVoltageToMeterValue(
+          chargingStation,
+          meterValue,
+          voltageMeasurand,
+          buildVersionedSampledValue
+        )
         for (
           let phase = 1;
           chargingStation.getNumberOfPhases() === 3 && phase <= chargingStation.getNumberOfPhases();
@@ -1438,14 +1470,16 @@ export const buildMeterValue = (
             connectorId,
             meterValue,
             voltageMeasurand,
-            phase
+            phase,
+            buildVersionedSampledValue
           )
           addLineToLineVoltageToMeterValue(
             chargingStation,
             connectorId,
             meterValue,
             voltageMeasurand,
-            phase
+            phase,
+            buildVersionedSampledValue
           )
         }
       }
@@ -1455,8 +1489,7 @@ export const buildMeterValue = (
         updateConnectorEnergyValues(connector, energyMeasurand.value)
         const unitDivider =
           energyMeasurand.template.unit === MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1
-        const energySampledValue = buildSampledValue(
-          chargingStation.stationInfo.ocppVersion,
+        const energySampledValue = buildVersionedSampledValue(
           energyMeasurand.template,
           roundTo(
             chargingStation.getEnergyActiveImportRegisterByTransactionId(transactionId) /
@@ -1486,8 +1519,7 @@ export const buildMeterValue = (
       // Power.Active.Import measurand
       const powerMeasurand = buildPowerMeasurandValue(chargingStation, connectorId)
       if (powerMeasurand?.values.allPhases != null) {
-        const powerSampledValue = buildSampledValue(
-          chargingStation.stationInfo.ocppVersion,
+        const powerSampledValue = buildVersionedSampledValue(
           powerMeasurand.template,
           powerMeasurand.values.allPhases
         )
@@ -1496,8 +1528,7 @@ export const buildMeterValue = (
       // Current.Import measurand
       const currentMeasurand = buildCurrentMeasurandValue(chargingStation, connectorId)
       if (currentMeasurand?.values.allPhases != null) {
-        const currentSampledValue = buildSampledValue(
-          chargingStation.stationInfo.ocppVersion,
+        const currentSampledValue = buildVersionedSampledValue(
           currentMeasurand.template,
           currentMeasurand.values.allPhases
         )
@@ -1520,30 +1551,43 @@ export const buildTransactionEndMeterValue = (
   connectorId: number,
   meterStop: number | undefined
 ): MeterValue => {
-  let meterValue: MeterValue
-  let sampledValueTemplate: SampledValueTemplate | undefined
-  let unitDivider: number
+  const sampledValueTemplate = getSampledValueTemplate(chargingStation, connectorId)
+  if (sampledValueTemplate == null) {
+    throw new BaseError(
+      `Missing MeterValues for default measurand '${MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER}' in template on connector id ${connectorId.toString()}`
+    )
+  }
+  const unitDivider = sampledValueTemplate.unit === MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1
   switch (chargingStation.stationInfo?.ocppVersion) {
-    case OCPPVersion.VERSION_16:
+    case OCPPVersion.VERSION_16: {
+      const meterValue: OCPP16MeterValue = {
+        sampledValue: [],
+        timestamp: new Date(),
+      }
+      meterValue.sampledValue.push(
+        buildSampledValueForOCPP16(
+          sampledValueTemplate,
+          roundTo((meterStop ?? 0) / unitDivider, 4),
+          MeterValueContext.TRANSACTION_END
+        )
+      )
+      return meterValue
+    }
     case OCPPVersion.VERSION_20:
-    case OCPPVersion.VERSION_201:
-      meterValue = {
+    case OCPPVersion.VERSION_201: {
+      const meterValue: OCPP20MeterValue = {
         sampledValue: [],
         timestamp: new Date(),
       }
-      // Energy.Active.Import.Register measurand (default)
-      sampledValueTemplate = getSampledValueTemplate(chargingStation, connectorId)
-      unitDivider = sampledValueTemplate?.unit === MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1
       meterValue.sampledValue.push(
-        buildSampledValue(
-          chargingStation.stationInfo.ocppVersion,
-          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-          sampledValueTemplate!,
+        buildSampledValueForOCPP20(
+          sampledValueTemplate,
           roundTo((meterStop ?? 0) / unitDivider, 4),
           MeterValueContext.TRANSACTION_END
         )
       )
       return meterValue
+    }
     default:
       throw new OCPPError(
         ErrorType.INTERNAL_ERROR,
@@ -1752,6 +1796,52 @@ function buildSampledValue (
   }
 }
 
+/**
+ * Builds an OCPP 1.6 sampled value from a template and measurement data.
+ * @param sampledValueTemplate - The sampled value template to use.
+ * @param value - The measured value.
+ * @param context - The reading context.
+ * @param phase - The phase of the measurement.
+ * @returns The built OCPP 1.6 sampled value.
+ */
+function buildSampledValueForOCPP16 (
+  sampledValueTemplate: SampledValueTemplate,
+  value: number,
+  context?: MeterValueContext,
+  phase?: MeterValuePhase
+): OCPP16SampledValue {
+  return buildSampledValue(
+    OCPPVersion.VERSION_16,
+    sampledValueTemplate,
+    value,
+    context,
+    phase
+  ) as OCPP16SampledValue
+}
+
+/**
+ * Builds an OCPP 2.0 sampled value from a template and measurement data.
+ * @param sampledValueTemplate - The sampled value template to use.
+ * @param value - The measured value.
+ * @param context - The reading context.
+ * @param phase - The phase of the measurement.
+ * @returns The built OCPP 2.0 sampled value.
+ */
+function buildSampledValueForOCPP20 (
+  sampledValueTemplate: SampledValueTemplate,
+  value: number,
+  context?: MeterValueContext,
+  phase?: MeterValuePhase
+): OCPP20SampledValue {
+  return buildSampledValue(
+    OCPPVersion.VERSION_20,
+    sampledValueTemplate,
+    value,
+    context,
+    phase
+  ) as OCPP20SampledValue
+}
+
 const getMeasurandDefaultContext = (measurandType: MeterValueMeasurand): MeterValueContext => {
   return MeterValueContext.SAMPLE_PERIODIC
 }
index dbf4ef6ff7edee979f936d0e71113975e3fb41c2..1a1d0d9727b0dd97eba189da2473f36952764f1a 100644 (file)
@@ -53,8 +53,10 @@ export class MikroOrmStorage extends Storage {
       await this.orm?.em.upsert({
         ...performanceStatistics,
         statisticsData: Array.from(performanceStatistics.statisticsData, ([name, value]) => ({
-          name,
           ...value,
+          measurementTimeSeries:
+            value.measurementTimeSeries != null ? [...value.measurementTimeSeries] : undefined,
+          name,
         })),
       } satisfies PerformanceRecord)
     } catch (error) {
index b3290bfa68525482f2ee88b5445e1a826c181aab..de11ef99092fe943047c76f550e3f5e11f590a13 100644 (file)
@@ -158,6 +158,7 @@ export {
   InstallCertificateStatusEnumType,
   InstallCertificateUseEnumType,
   Iso15118EVCertificateStatusEnumType,
+  MessageTriggerEnumType,
   OCPP20ComponentName,
   OCPP20UnitEnumType,
   type OCSPRequestDataType,
@@ -165,6 +166,8 @@ export {
   ReportBaseEnumType,
   ResetEnumType,
   ResetStatusEnumType,
+  TriggerMessageStatusEnumType,
+  UnlockStatusEnumType,
 } from './ocpp/2.0/Common.js'
 export {
   OCPP20LocationEnumType,
@@ -199,6 +202,8 @@ export {
   type OCPP20SetVariablesRequest,
   type OCPP20SignCertificateRequest,
   type OCPP20StatusNotificationRequest,
+  type OCPP20TriggerMessageRequest,
+  type OCPP20UnlockConnectorRequest,
 } from './ocpp/2.0/Requests.js'
 export type {
   OCPP20BootNotificationResponse,
@@ -219,6 +224,8 @@ export type {
   OCPP20SetVariablesResponse,
   OCPP20SignCertificateResponse,
   OCPP20StatusNotificationResponse,
+  OCPP20TriggerMessageResponse,
+  OCPP20UnlockConnectorResponse,
 } from './ocpp/2.0/Responses.js'
 export {
   type ComponentType,
@@ -261,6 +268,7 @@ export {
   ChargingProfileKindType,
   ChargingProfilePurposeType,
   ChargingRateUnitType,
+  type ChargingSchedule,
   type ChargingSchedulePeriod,
   RecurrencyKindType,
 } from './ocpp/ChargingProfile.js'
index b7a68402e01fdd32b95cad7653f6d484893b1a27..644e0d4c3bf4e566da19b827289790e4795701af 100644 (file)
@@ -10,12 +10,12 @@ import type {
 import type { OCPP16StandardParametersKey, OCPP16VendorParametersKey } from './Configuration.js'
 import type { OCPP16DiagnosticsStatus } from './DiagnosticsStatus.js'
 
-export const enum OCPP16AvailabilityType {
+export enum OCPP16AvailabilityType {
   Inoperative = 'Inoperative',
   Operative = 'Operative',
 }
 
-export const enum OCPP16FirmwareStatus {
+export enum OCPP16FirmwareStatus {
   Downloaded = 'Downloaded',
   DownloadFailed = 'DownloadFailed',
   Downloading = 'Downloading',
@@ -25,7 +25,7 @@ export const enum OCPP16FirmwareStatus {
   Installing = 'Installing',
 }
 
-export const enum OCPP16IncomingRequestCommand {
+export enum OCPP16IncomingRequestCommand {
   CANCEL_RESERVATION = 'CancelReservation',
   CHANGE_AVAILABILITY = 'ChangeAvailability',
   CHANGE_CONFIGURATION = 'ChangeConfiguration',
@@ -54,7 +54,7 @@ export enum OCPP16MessageTrigger {
   StatusNotification = 'StatusNotification',
 }
 
-export const enum OCPP16RequestCommand {
+export enum OCPP16RequestCommand {
   AUTHORIZE = 'Authorize',
   BOOT_NOTIFICATION = 'BootNotification',
   DATA_TRANSFER = 'DataTransfer',
index d0fddacebb31c16c006c2d9233b0b4517a04398a..6dc0358065370752ea7f59edcb021ac15043336a 100644 (file)
@@ -4,13 +4,13 @@ import type { GenericStatus, RegistrationStatusEnumType } from '../Common.js'
 import type { OCPPConfigurationKey } from '../Configuration.js'
 import type { OCPP16ChargingSchedule } from './ChargingProfile.js'
 
-export const enum OCPP16AvailabilityStatus {
+export enum OCPP16AvailabilityStatus {
   ACCEPTED = 'Accepted',
   REJECTED = 'Rejected',
   SCHEDULED = 'Scheduled',
 }
 
-export const enum OCPP16ChargingProfileStatus {
+export enum OCPP16ChargingProfileStatus {
   ACCEPTED = 'Accepted',
   NOT_SUPPORTED = 'NotSupported',
   REJECTED = 'Rejected',
@@ -21,14 +21,14 @@ export enum OCPP16ClearChargingProfileStatus {
   UNKNOWN = 'Unknown',
 }
 
-export const enum OCPP16ConfigurationStatus {
+export enum OCPP16ConfigurationStatus {
   ACCEPTED = 'Accepted',
   NOT_SUPPORTED = 'NotSupported',
   REBOOT_REQUIRED = 'RebootRequired',
   REJECTED = 'Rejected',
 }
 
-export const enum OCPP16DataTransferStatus {
+export enum OCPP16DataTransferStatus {
   ACCEPTED = 'Accepted',
   REJECTED = 'Rejected',
   UNKNOWN_MESSAGE_ID = 'UnknownMessageId',
@@ -50,7 +50,7 @@ export enum OCPP16TriggerMessageStatus {
   REJECTED = 'Rejected',
 }
 
-export const enum OCPP16UnlockStatus {
+export enum OCPP16UnlockStatus {
   NOT_SUPPORTED = 'NotSupported',
   UNLOCK_FAILED = 'UnlockFailed',
   UNLOCKED = 'Unlocked',
index ff9c8fcd5b94a2ebc1c46504c3121e6de1afd711..ffec9145b8f34966a616120b751e0d1e53592b1d 100644 (file)
@@ -89,6 +89,20 @@ export enum Iso15118EVCertificateStatusEnumType {
   Failed = 'Failed',
 }
 
+export enum MessageTriggerEnumType {
+  BootNotification = 'BootNotification',
+  FirmwareStatusNotification = 'FirmwareStatusNotification',
+  Heartbeat = 'Heartbeat',
+  LogStatusNotification = 'LogStatusNotification',
+  MeterValues = 'MeterValues',
+  PublishFirmwareStatusNotification = 'PublishFirmwareStatusNotification',
+  SignChargingStationCertificate = 'SignChargingStationCertificate',
+  SignCombinedCertificate = 'SignCombinedCertificate',
+  SignV2GCertificate = 'SignV2GCertificate',
+  StatusNotification = 'StatusNotification',
+  TransactionEvent = 'TransactionEvent',
+}
+
 export enum OCPP20ComponentName {
   // Physical and Logical Components
   AccessBarrier = 'AccessBarrier',
@@ -272,6 +286,19 @@ export enum ResetStatusEnumType {
   Scheduled = 'Scheduled',
 }
 
+export enum TriggerMessageStatusEnumType {
+  Accepted = 'Accepted',
+  NotImplemented = 'NotImplemented',
+  Rejected = 'Rejected',
+}
+
+export enum UnlockStatusEnumType {
+  OngoingAuthorizedTransaction = 'OngoingAuthorizedTransaction',
+  UnknownConnector = 'UnknownConnector',
+  Unlocked = 'Unlocked',
+  UnlockFailed = 'UnlockFailed',
+}
+
 export interface CertificateHashDataChainType extends JsonObject {
   certificateHashData: CertificateHashDataType
   certificateType: GetCertificateIdUseEnumType
index 5e0e38dae58fa18058696395540f299b92fbee6b..b7a9b9522abf4cde3677dd1d70605186e3d88d4b 100644 (file)
@@ -10,6 +10,7 @@ import type {
   CustomDataType,
   GetCertificateIdUseEnumType,
   InstallCertificateUseEnumType,
+  MessageTriggerEnumType,
   OCSPRequestDataType,
   ReportBaseEnumType,
   ResetEnumType,
@@ -17,6 +18,7 @@ import type {
 import type {
   OCPP20ChargingProfileType,
   OCPP20ConnectorStatusEnumType,
+  OCPP20EVSEType,
   OCPP20IdTokenType,
 } from './Transaction.js'
 import type {
@@ -25,7 +27,7 @@ import type {
   ReportDataType,
 } from './Variables.js'
 
-export const enum OCPP20IncomingRequestCommand {
+export enum OCPP20IncomingRequestCommand {
   CERTIFICATE_SIGNED = 'CertificateSigned',
   CLEAR_CACHE = 'ClearCache',
   DELETE_CERTIFICATE = 'DeleteCertificate',
@@ -41,7 +43,7 @@ export const enum OCPP20IncomingRequestCommand {
   UNLOCK_CONNECTOR = 'UnlockConnector',
 }
 
-export const enum OCPP20RequestCommand {
+export enum OCPP20RequestCommand {
   BOOT_NOTIFICATION = 'BootNotification',
   GET_15118_EV_CERTIFICATE = 'Get15118EVCertificate',
   GET_CERTIFICATE_STATUS = 'GetCertificateStatus',
@@ -154,3 +156,15 @@ export interface OCPP20StatusNotificationRequest extends JsonObject {
   evseId: number
   timestamp: Date
 }
+
+export interface OCPP20TriggerMessageRequest extends JsonObject {
+  customData?: CustomDataType
+  evse?: OCPP20EVSEType
+  requestedMessage: MessageTriggerEnumType
+}
+
+export interface OCPP20UnlockConnectorRequest extends JsonObject {
+  connectorId: number
+  customData?: CustomDataType
+  evseId: number
+}
index 3471ab8a03ce58b5e81f947c4f8d24ff448b2648..4a25491b9757ecfd62ed68ba125b88f9956be814 100644 (file)
@@ -15,6 +15,8 @@ import type {
   Iso15118EVCertificateStatusEnumType,
   ResetStatusEnumType,
   StatusInfoType,
+  TriggerMessageStatusEnumType,
+  UnlockStatusEnumType,
 } from './Common.js'
 import type { RequestStartStopStatusEnumType } from './Transaction.js'
 import type { OCPP20GetVariableResultType, OCPP20SetVariableResultType } from './Variables.js'
@@ -121,3 +123,17 @@ export interface OCPP20SignCertificateResponse extends JsonObject {
 }
 
 export type OCPP20StatusNotificationResponse = EmptyObject
+
+export type { OCPP20TransactionEventResponse } from './Transaction.js'
+
+export interface OCPP20TriggerMessageResponse extends JsonObject {
+  customData?: CustomDataType
+  status: TriggerMessageStatusEnumType
+  statusInfo?: StatusInfoType
+}
+
+export interface OCPP20UnlockConnectorResponse extends JsonObject {
+  customData?: CustomDataType
+  status: UnlockStatusEnumType
+  statusInfo?: StatusInfoType
+}
index ad5b849af8c2cdd3ea8b09768a0618b53be27ade..bf11c9d5ee0418d58c2c74ceaf26a1f9bb468a1d 100644 (file)
@@ -2,6 +2,7 @@ import type { JsonObject } from '../../JsonType.js'
 import type { UUIDv4 } from '../../UUID.js'
 import type { CustomDataType } from './Common.js'
 import type { OCPP20MeterValue } from './MeterValues.js'
+import type { OCPP20IncomingRequestCommand } from './Requests.js'
 
 export enum CostKindEnumType {
   CarbonDioxideEmission = 'CarbonDioxideEmission',
index fc68192961be0fb8272ad175530119289db9ea81..0d56994dd4608e6ee37c44a43a404c3d08afffb5 100644 (file)
@@ -1,7 +1,4 @@
-export type { OCPP20CommonDataModelType, OCPP20CustomDataType } from './Common.js'
 export type { OCPP20MeterValue } from './MeterValues.js'
-export type { OCPP20RequestsType } from './Requests.js'
-export type { OCPP20ResponsesType } from './Responses.js'
 export type {
   OCPP20ChargingStateEnumType,
   OCPP20EVSEType,
@@ -12,8 +9,3 @@ export type {
   OCPP20TransactionEventOptions,
   OCPP20TransactionEventRequest,
 } from './Transaction.js'
-export type {
-  OCPP20GetVariablesStatusEnumType,
-  OCPP20VariableAttributeType,
-  OCPP20VariableType,
-} from './Variables.js'
index 29cf772726a370b766eefce2c4af51f9369ebd3a..aaa9f1f5cbf548ab00d3a4f2e6bffa9ebf15b538 100644 (file)
@@ -3,6 +3,7 @@ import {
   OCPP16ChargingProfileKindType,
   OCPP16ChargingProfilePurposeType,
   OCPP16ChargingRateUnitType,
+  type OCPP16ChargingSchedule,
   type OCPP16ChargingSchedulePeriod,
   OCPP16RecurrencyKindType,
 } from './1.6/ChargingProfile.js'
@@ -12,11 +13,14 @@ import {
   type OCPP20ChargingProfileType,
   OCPP20ChargingRateUnitEnumType,
   type OCPP20ChargingSchedulePeriodType,
+  type OCPP20ChargingScheduleType,
   OCPP20RecurrencyKindEnumType,
 } from './2.0/Transaction.js'
 
 export type ChargingProfile = OCPP16ChargingProfile | OCPP20ChargingProfileType
 
+export type ChargingSchedule = OCPP16ChargingSchedule | OCPP20ChargingScheduleType
+
 export type ChargingSchedulePeriod = OCPP16ChargingSchedulePeriod | OCPP20ChargingSchedulePeriodType
 
 export const ChargingProfilePurposeType = {
index 4800dcd41df79c7cc3cd90516787589d101a05dd..7190d9a4483f3ccb53095b5c76eb2d645651d68e 100644 (file)
@@ -1,6 +1,6 @@
 import type { JsonObject } from '../JsonType.js'
 
-export const enum GenericStatus {
+export enum GenericStatus {
   Accepted = 'Accepted',
   Rejected = 'Rejected',
 }
index 79bd00b39427b93f8a005b1799699b51bc05a407..f3a93972ebf9f30b1bca57afab83cf135daceeee 100644 (file)
@@ -2,6 +2,7 @@ import type { ChargingStation } from '../../charging-station/index.js'
 import type { OCPPError } from '../../exception/index.js'
 import type { JsonType } from '../JsonType.js'
 import type { OCPP16MeterValuesRequest } from './1.6/MeterValues.js'
+import type { OCPP20MeterValuesRequest } from './2.0/MeterValues.js'
 import type { MessageType } from './MessageType.js'
 
 import { OCPP16DiagnosticsStatus } from './1.6/DiagnosticsStatus.js'
@@ -82,7 +83,7 @@ export const MessageTrigger = {
 // eslint-disable-next-line @typescript-eslint/no-redeclare
 export type MessageTrigger = OCPP16MessageTrigger
 
-export type MeterValuesRequest = OCPP16MeterValuesRequest
+export type MeterValuesRequest = OCPP16MeterValuesRequest | OCPP20MeterValuesRequest
 
 export type ResponseCallback = (payload: JsonType, requestPayload: JsonType) => void
 
index e6b88f00dfe807420e92b6a0d625cf500a7c63f8..ef7e95eda52948e64e41b4ff89a51f6435e11f78 100644 (file)
@@ -1,6 +1,7 @@
 import type { ChargingStation } from '../../charging-station/index.js'
 import type { JsonType } from '../JsonType.js'
 import type { OCPP16MeterValuesResponse } from './1.6/MeterValues.js'
+import type { OCPP20MeterValuesResponse } from './2.0/MeterValues.js'
 import type { OCPP20BootNotificationResponse, OCPP20ClearCacheResponse } from './2.0/Responses.js'
 import type { ErrorType } from './ErrorType.js'
 import type { MessageType } from './MessageType.js'
@@ -41,7 +42,8 @@ export type FirmwareStatusNotificationResponse = OCPP16FirmwareStatusNotificatio
 
 export type HeartbeatResponse = OCPP16HeartbeatResponse
 
-export type MeterValuesResponse = OCPP16MeterValuesResponse
+// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents
+export type MeterValuesResponse = OCPP16MeterValuesResponse | OCPP20MeterValuesResponse
 
 export type Response = [MessageType.CALL_RESULT_MESSAGE, string, JsonType]
 
index 4ea1112fed5cb713d7959e71b66acf431f173c04..00776945e02240387403f0188eefa0754dce18e9 100644 (file)
@@ -369,8 +369,10 @@ await describe('ChargingStation Connector and EVSE State', async () => {
       expect(station.inPendingState()).toBe(true)
 
       // Act - transition from PENDING to ACCEPTED
-      station.bootNotificationResponse.status = RegistrationStatusEnumType.ACCEPTED
-      station.bootNotificationResponse.currentTime = new Date()
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      station.bootNotificationResponse!.status = RegistrationStatusEnumType.ACCEPTED
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      station.bootNotificationResponse!.currentTime = new Date()
 
       // Assert
       expect(station.inAcceptedState()).toBe(true)
@@ -386,8 +388,10 @@ await describe('ChargingStation Connector and EVSE State', async () => {
       expect(station.inPendingState()).toBe(true)
 
       // Act - transition from PENDING to REJECTED
-      station.bootNotificationResponse.status = RegistrationStatusEnumType.REJECTED
-      station.bootNotificationResponse.currentTime = new Date()
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      station.bootNotificationResponse!.status = RegistrationStatusEnumType.REJECTED
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      station.bootNotificationResponse!.currentTime = new Date()
 
       // Assert
       expect(station.inRejectedState()).toBe(true)
index 5672cafead408cfb2f3bfd55062b3e0035d39dbe..af03adac5c9b55f98b02420210c06011827f4350 100644 (file)
@@ -104,13 +104,13 @@ await describe('ChargingStation Lifecycle', async () => {
       station.start()
 
       // Assert initial state
-      expect(station.stopping).toBe(false)
+      expect((station as unknown as { stopping: boolean }).stopping).toBe(false)
 
       // Act
       await station.stop()
 
       // Assert - after stop() completes, stopping should be false
-      expect(station.stopping).toBe(false)
+      expect((station as unknown as { stopping: boolean }).stopping).toBe(false)
       expect(station.started).toBe(false)
     })
 
index 166aeaa87400e55f6a8c02d273949d8be8958200..726e1404407ffe89dbb78cff82e8d6e142f7fcca 100644 (file)
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, it } from 'node:test'
 
 import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
 
-import { RegistrationStatusEnumType } from '../../src/types/index.js'
+import { OCPP16RequestCommand, RegistrationStatusEnumType } from '../../src/types/index.js'
 import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
 import { TEST_HEARTBEAT_INTERVAL_MS } from './ChargingStationTestConstants.js'
 import { cleanupChargingStation, createMockChargingStation } from './ChargingStationTestUtils.js'
@@ -17,7 +17,7 @@ await describe('ChargingStation Resilience', async () => {
     let station: ChargingStation
 
     beforeEach(() => {
-      station = undefined
+      station = undefined as unknown as ChargingStation
     })
 
     afterEach(() => {
@@ -66,15 +66,16 @@ await describe('ChargingStation Resilience', async () => {
       // Arrange
       const result = createMockChargingStation({ connectorsCount: 1 })
       station = result.station
+      const stationWithRetryCount = station as unknown as { wsConnectionRetryCount: number }
 
       // Assert - Initial retry count should be 0
-      expect(station.wsConnectionRetryCount).toBe(0)
+      expect(stationWithRetryCount.wsConnectionRetryCount).toBe(0)
 
       // Act - Increment retry count manually (simulating reconnection attempt)
-      station.wsConnectionRetryCount = 1
+      stationWithRetryCount.wsConnectionRetryCount = 1
 
       // Assert - Count should be incremented
-      expect(station.wsConnectionRetryCount).toBe(1)
+      expect(stationWithRetryCount.wsConnectionRetryCount).toBe(1)
     })
 
     await it('should support exponential backoff configuration', () => {
@@ -92,13 +93,14 @@ await describe('ChargingStation Resilience', async () => {
       // Arrange
       const result = createMockChargingStation({ connectorsCount: 1 })
       station = result.station
-      station.wsConnectionRetryCount = 5 // Simulate some retries
+      const stationWithRetryCount = station as unknown as { wsConnectionRetryCount: number }
+      stationWithRetryCount.wsConnectionRetryCount = 5
 
       // Act - Reset retry count (as would happen on successful reconnection)
-      station.wsConnectionRetryCount = 0
+      stationWithRetryCount.wsConnectionRetryCount = 0
 
       // Assert
-      expect(station.wsConnectionRetryCount).toBe(0)
+      expect(stationWithRetryCount.wsConnectionRetryCount).toBe(0)
     })
 
     // -------------------------------------------------------------------------
@@ -145,7 +147,16 @@ await describe('ChargingStation Resilience', async () => {
 
       // Add a request with specific message ID to simulate duplicate
       const messageId = 'duplicate-uuid-123'
-      station.requests.set(messageId, ['callback', 'errorCallback', 'TestCommand'])
+      // eslint-disable-next-line @typescript-eslint/no-empty-function
+      const responseCallback = (): void => {}
+      // eslint-disable-next-line @typescript-eslint/no-empty-function
+      const errorCallback = (): void => {}
+      station.requests.set(messageId, [
+        responseCallback,
+        errorCallback,
+        OCPP16RequestCommand.HEARTBEAT,
+        {},
+      ])
 
       // Assert - Request with duplicate ID exists
       expect(station.requests.has(messageId)).toBe(true)
@@ -268,8 +279,21 @@ await describe('ChargingStation Resilience', async () => {
       station = result.station
 
       // Add some pending requests
-      station.requests.set('req-1', ['callback1', 'errorCallback1', 'Command1'])
-      station.requests.set('req-2', ['callback2', 'errorCallback2', 'Command2'])
+      // eslint-disable-next-line @typescript-eslint/no-empty-function
+      const callback1 = (): void => {}
+      // eslint-disable-next-line @typescript-eslint/no-empty-function
+      const errorCallback1 = (): void => {}
+      // eslint-disable-next-line @typescript-eslint/no-empty-function
+      const callback2 = (): void => {}
+      // eslint-disable-next-line @typescript-eslint/no-empty-function
+      const errorCallback2 = (): void => {}
+      station.requests.set('req-1', [
+        callback1,
+        errorCallback1,
+        OCPP16RequestCommand.BOOT_NOTIFICATION,
+        {},
+      ])
+      station.requests.set('req-2', [callback2, errorCallback2, OCPP16RequestCommand.HEARTBEAT, {}])
 
       // Act - Cleanup station
       cleanupChargingStation(station)
@@ -283,7 +307,7 @@ await describe('ChargingStation Resilience', async () => {
     let station: ChargingStation
 
     beforeEach(() => {
-      station = undefined
+      station = undefined as unknown as ChargingStation
     })
 
     afterEach(() => {
@@ -312,8 +336,9 @@ await describe('ChargingStation Resilience', async () => {
       station.bufferMessage(testMessage)
 
       // Assert - Message should be queued but not sent
-      expect(station.messageQueue.length).toBe(1)
-      expect(station.messageQueue[0]).toBe(testMessage)
+      const stationWithQueue = station as unknown as { messageQueue: string[] }
+      expect(stationWithQueue.messageQueue.length).toBe(1)
+      expect(stationWithQueue.messageQueue[0]).toBe(testMessage)
       expect(mocks.webSocket.sentMessages.length).toBe(0)
     })
 
@@ -333,7 +358,8 @@ await describe('ChargingStation Resilience', async () => {
 
       // Note: Due to async nature, the message may be sent or buffered depending on timing
       // This test verifies the message is queued at minimum
-      expect(station.messageQueue.length).toBeGreaterThanOrEqual(0)
+      const stationWithQueue = station as unknown as { messageQueue: string[] }
+      expect(stationWithQueue.messageQueue.length).toBeGreaterThanOrEqual(0)
     })
 
     await it('should flush messages in FIFO order when connection restored', () => {
@@ -354,10 +380,11 @@ await describe('ChargingStation Resilience', async () => {
       station.bufferMessage(msg3)
 
       // Assert - All messages should be buffered
-      expect(station.messageQueue.length).toBe(3)
-      expect(station.messageQueue[0]).toBe(msg1)
-      expect(station.messageQueue[1]).toBe(msg2)
-      expect(station.messageQueue[2]).toBe(msg3)
+      const stationWithQueue = station as unknown as { messageQueue: string[] }
+      expect(stationWithQueue.messageQueue.length).toBe(3)
+      expect(stationWithQueue.messageQueue[0]).toBe(msg1)
+      expect(stationWithQueue.messageQueue[1]).toBe(msg2)
+      expect(stationWithQueue.messageQueue[2]).toBe(msg3)
       expect(mocks.webSocket.sentMessages.length).toBe(0)
     })
 
@@ -382,9 +409,10 @@ await describe('ChargingStation Resilience', async () => {
       }
 
       // Assert - Verify FIFO order
-      expect(station.messageQueue.length).toBe(5)
+      const stationWithQueue = station as unknown as { messageQueue: string[] }
+      expect(stationWithQueue.messageQueue.length).toBe(5)
       for (let i = 0; i < messages.length; i++) {
-        expect(station.messageQueue[i]).toBe(messages[i])
+        expect(stationWithQueue.messageQueue[i]).toBe(messages[i])
       }
     })
 
@@ -404,12 +432,13 @@ await describe('ChargingStation Resilience', async () => {
       }
 
       // Assert - All messages should be buffered
-      expect(station.messageQueue.length).toBe(messageCount)
+      const stationWithQueue = station as unknown as { messageQueue: string[] }
+      expect(stationWithQueue.messageQueue.length).toBe(messageCount)
       expect(mocks.webSocket.sentMessages.length).toBe(0)
 
       // Verify first and last message are in correct positions
-      expect(station.messageQueue[0]).toContain('msg-0')
-      expect(station.messageQueue[messageCount - 1]).toContain(
+      expect(stationWithQueue.messageQueue[0]).toContain('msg-0')
+      expect(stationWithQueue.messageQueue[messageCount - 1]).toContain(
         `msg-${(messageCount - 1).toString()}`
       )
     })
@@ -434,7 +463,8 @@ await describe('ChargingStation Resilience', async () => {
       const initialSentCount = mocks.webSocket.sentMessages.length
 
       // Assert - Message should remain buffered
-      expect(station.messageQueue.length).toBe(1)
+      const stationWithQueue = station as unknown as { messageQueue: string[] }
+      expect(stationWithQueue.messageQueue.length).toBe(1)
       expect(mocks.webSocket.sentMessages.length).toBe(initialSentCount)
     })
 
@@ -449,18 +479,19 @@ await describe('ChargingStation Resilience', async () => {
 
       // Act - Buffer message
       station.bufferMessage(testMessage)
-      const bufferedCount = station.messageQueue.length
+      const stationWithQueue = station as unknown as { messageQueue: string[] }
+      const bufferedCount = stationWithQueue.messageQueue.length
 
       // Assert - Message is buffered
       expect(bufferedCount).toBe(1)
 
       // Now simulate successful send by manually removing (simulating what sendMessageBuffer does)
-      if (station.messageQueue.length > 0) {
-        station.messageQueue.shift()
+      if (stationWithQueue.messageQueue.length > 0) {
+        stationWithQueue.messageQueue.shift()
       }
 
       // Assert - Buffer should be cleared
-      expect(station.messageQueue.length).toBe(0)
+      expect(stationWithQueue.messageQueue.length).toBe(0)
     })
 
     await it('should handle rapid buffer/reconnect cycles without message loss', () => {
@@ -486,9 +517,10 @@ await describe('ChargingStation Resilience', async () => {
       }
 
       // Assert - All messages from all cycles should be buffered in order
-      expect(station.messageQueue.length).toBe(totalExpectedMessages)
-      expect(station.messageQueue[0]).toContain('cycle-0-msg-0')
-      expect(station.messageQueue[totalExpectedMessages - 1]).toContain(
+      const stationWithQueue = station as unknown as { messageQueue: string[] }
+      expect(stationWithQueue.messageQueue.length).toBe(totalExpectedMessages)
+      expect(stationWithQueue.messageQueue[0]).toContain('cycle-0-msg-0')
+      expect(stationWithQueue.messageQueue[totalExpectedMessages - 1]).toContain(
         `cycle-${(cycleCount - 1).toString()}-msg-${(messagesPerCycle - 1).toString()}`
       )
     })
index e7b6915df4fe7a44be6f056034fb3b367d7b8e67..24f42b455aa82ff269cdc5d9d3f5c819d5d23543 100644 (file)
@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, it } from 'node:test'
 
 import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
 
+import { OCPPVersion } from '../../src/types/index.js'
 import { standardCleanup, withMockTimers } from '../helpers/TestLifecycleHelpers.js'
 import { TEST_HEARTBEAT_INTERVAL_MS, TEST_ID_TAG } from './ChargingStationTestConstants.js'
 import { cleanupChargingStation, createMockChargingStation } from './ChargingStationTestUtils.js'
@@ -592,7 +593,10 @@ await describe('ChargingStation Transaction Management', async () => {
     await it('should create transaction updated interval when startTxUpdatedInterval() is called for OCPP 2.0', async t => {
       await withMockTimers(t, ['setInterval'], () => {
         // Arrange
-        const result = createMockChargingStation({ connectorsCount: 2, ocppVersion: '2.0' })
+        const result = createMockChargingStation({
+          connectorsCount: 2,
+          ocppVersion: OCPPVersion.VERSION_20,
+        })
         station = result.station
         const connector1 = station.getConnectorStatus(1)
         if (connector1 != null) {
@@ -614,7 +618,10 @@ await describe('ChargingStation Transaction Management', async () => {
     await it('should clear transaction updated interval when stopTxUpdatedInterval() is called', async t => {
       await withMockTimers(t, ['setInterval'], () => {
         // Arrange
-        const result = createMockChargingStation({ connectorsCount: 2, ocppVersion: '2.0' })
+        const result = createMockChargingStation({
+          connectorsCount: 2,
+          ocppVersion: OCPPVersion.VERSION_20,
+        })
         station = result.station
         const connector1 = station.getConnectorStatus(1)
         if (connector1 != null) {
index d1c0b6852a493a231d68cd1037dc3ce350dbbab4..0c8cecf5f6851a16fc671a2db3c4c0a4a385bdb2 100644 (file)
@@ -182,7 +182,8 @@ await describe('ChargingStation', async () => {
 
       // Buffer messages while disconnected
       station.bufferMessage('["2","uuid-2","StatusNotification",{}]')
-      expect(station.messageQueue.length).toBe(1)
+      const stationWithQueue = station as unknown as { messageQueue: string[] }
+      expect(stationWithQueue.messageQueue.length).toBe(1)
 
       cleanupChargingStation(station)
       station = undefined
index a778d26b71279d9d4bfcf030d874b5410586b4a6..b3747d37b4cc9423ce3ed61150fa1e9609c2f2f3 100644 (file)
@@ -77,7 +77,7 @@ await describe('Helpers', async () => {
     // For validation edge cases, we need to manually create invalid states
     // since the factory is designed to create valid configurations
     const { station: stationNoInfo } = createMockChargingStation({
-      TEST_CHARGING_STATION_BASE_NAME,
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
     })
     stationNoInfo.stationInfo = undefined
 
@@ -91,7 +91,7 @@ await describe('Helpers', async () => {
     // Arrange
     // For validation edge cases, manually create empty stationInfo
     const { station: stationEmptyInfo } = createMockChargingStation({
-      TEST_CHARGING_STATION_BASE_NAME,
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
     })
     stationEmptyInfo.stationInfo = {} as ChargingStationInfo
 
@@ -104,8 +104,8 @@ await describe('Helpers', async () => {
   await it('should throw when chargingStationId is undefined', () => {
     // Arrange
     const { station: stationMissingId } = createMockChargingStation({
-      stationInfo: { chargingStationId: undefined, TEST_CHARGING_STATION_BASE_NAME },
-      TEST_CHARGING_STATION_BASE_NAME,
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      stationInfo: { baseName: TEST_CHARGING_STATION_BASE_NAME, chargingStationId: undefined },
     })
 
     // Act & Assert
@@ -117,8 +117,8 @@ await describe('Helpers', async () => {
   await it('should throw when chargingStationId is empty string', () => {
     // Arrange
     const { station: stationEmptyId } = createMockChargingStation({
-      stationInfo: { chargingStationId: '', TEST_CHARGING_STATION_BASE_NAME },
-      TEST_CHARGING_STATION_BASE_NAME,
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      stationInfo: { baseName: TEST_CHARGING_STATION_BASE_NAME, chargingStationId: '' },
     })
 
     // Act & Assert
@@ -130,12 +130,12 @@ await describe('Helpers', async () => {
   await it('should throw when hashId is undefined', () => {
     // Arrange
     const { station: stationMissingHash } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       stationInfo: {
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
         chargingStationId: getChargingStationId(1, chargingStationTemplate),
         hashId: undefined,
-        TEST_CHARGING_STATION_BASE_NAME,
       },
-      TEST_CHARGING_STATION_BASE_NAME,
     })
 
     // Act & Assert
@@ -151,12 +151,12 @@ await describe('Helpers', async () => {
   await it('should throw when hashId is empty string', () => {
     // Arrange
     const { station: stationEmptyHash } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       stationInfo: {
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
         chargingStationId: getChargingStationId(1, chargingStationTemplate),
         hashId: '',
-        TEST_CHARGING_STATION_BASE_NAME,
       },
-      TEST_CHARGING_STATION_BASE_NAME,
     })
 
     // Act & Assert
@@ -172,13 +172,13 @@ await describe('Helpers', async () => {
   await it('should throw when templateIndex is undefined', () => {
     // Arrange
     const { station: stationMissingTemplate } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       stationInfo: {
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
         chargingStationId: getChargingStationId(1, chargingStationTemplate),
         hashId: getHashId(1, chargingStationTemplate),
         templateIndex: undefined,
-        TEST_CHARGING_STATION_BASE_NAME,
       },
-      TEST_CHARGING_STATION_BASE_NAME,
     })
 
     // Act & Assert
@@ -194,13 +194,13 @@ await describe('Helpers', async () => {
   await it('should throw when templateIndex is zero', () => {
     // Arrange
     const { station: stationInvalidTemplate } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       stationInfo: {
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
         chargingStationId: getChargingStationId(1, chargingStationTemplate),
         hashId: getHashId(1, chargingStationTemplate),
         templateIndex: 0,
-        TEST_CHARGING_STATION_BASE_NAME,
       },
-      TEST_CHARGING_STATION_BASE_NAME,
     })
 
     // Act & Assert
@@ -216,14 +216,14 @@ await describe('Helpers', async () => {
   await it('should throw when templateName is undefined', () => {
     // Arrange
     const { station: stationMissingName } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       stationInfo: {
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
         chargingStationId: getChargingStationId(1, chargingStationTemplate),
         hashId: getHashId(1, chargingStationTemplate),
         templateIndex: 1,
         templateName: undefined,
-        TEST_CHARGING_STATION_BASE_NAME,
       },
-      TEST_CHARGING_STATION_BASE_NAME,
     })
 
     // Act & Assert
@@ -239,14 +239,14 @@ await describe('Helpers', async () => {
   await it('should throw when templateName is empty string', () => {
     // Arrange
     const { station: stationEmptyName } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       stationInfo: {
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
         chargingStationId: getChargingStationId(1, chargingStationTemplate),
         hashId: getHashId(1, chargingStationTemplate),
         templateIndex: 1,
         templateName: '',
-        TEST_CHARGING_STATION_BASE_NAME,
       },
-      TEST_CHARGING_STATION_BASE_NAME,
     })
 
     // Act & Assert
@@ -262,15 +262,15 @@ await describe('Helpers', async () => {
   await it('should throw when maximumPower is undefined', () => {
     // Arrange
     const { station: stationMissingPower } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       stationInfo: {
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
         chargingStationId: getChargingStationId(1, chargingStationTemplate),
         hashId: getHashId(1, chargingStationTemplate),
         maximumPower: undefined,
         templateIndex: 1,
         templateName: 'test-template.json',
-        TEST_CHARGING_STATION_BASE_NAME,
       },
-      TEST_CHARGING_STATION_BASE_NAME,
     })
 
     // Act & Assert
@@ -286,15 +286,15 @@ await describe('Helpers', async () => {
   await it('should throw when maximumPower is zero', () => {
     // Arrange
     const { station: stationInvalidPower } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       stationInfo: {
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
         chargingStationId: getChargingStationId(1, chargingStationTemplate),
         hashId: getHashId(1, chargingStationTemplate),
         maximumPower: 0,
         templateIndex: 1,
         templateName: 'test-template.json',
-        TEST_CHARGING_STATION_BASE_NAME,
       },
-      TEST_CHARGING_STATION_BASE_NAME,
     })
 
     // Act & Assert
@@ -310,16 +310,16 @@ await describe('Helpers', async () => {
   await it('should throw when maximumAmperage is undefined', () => {
     // Arrange
     const { station: stationMissingAmperage } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       stationInfo: {
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
         chargingStationId: getChargingStationId(1, chargingStationTemplate),
         hashId: getHashId(1, chargingStationTemplate),
         maximumAmperage: undefined,
         maximumPower: 12000,
         templateIndex: 1,
         templateName: 'test-template.json',
-        TEST_CHARGING_STATION_BASE_NAME,
       },
-      TEST_CHARGING_STATION_BASE_NAME,
     })
 
     // Act & Assert
@@ -335,16 +335,16 @@ await describe('Helpers', async () => {
   await it('should throw when maximumAmperage is zero', () => {
     // Arrange
     const { station: stationInvalidAmperage } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       stationInfo: {
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
         chargingStationId: getChargingStationId(1, chargingStationTemplate),
         hashId: getHashId(1, chargingStationTemplate),
         maximumAmperage: 0,
         maximumPower: 12000,
         templateIndex: 1,
         templateName: 'test-template.json',
-        TEST_CHARGING_STATION_BASE_NAME,
       },
-      TEST_CHARGING_STATION_BASE_NAME,
     })
 
     // Act & Assert
@@ -360,16 +360,16 @@ await describe('Helpers', async () => {
   await it('should pass validation with complete valid configuration', () => {
     // Arrange
     const { station: validStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       stationInfo: {
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
         chargingStationId: getChargingStationId(1, chargingStationTemplate),
         hashId: getHashId(1, chargingStationTemplate),
         maximumAmperage: 16,
         maximumPower: 12000,
         templateIndex: 1,
         templateName: 'test-template.json',
-        TEST_CHARGING_STATION_BASE_NAME,
       },
-      TEST_CHARGING_STATION_BASE_NAME,
     })
 
     // Act & Assert
@@ -381,9 +381,11 @@ await describe('Helpers', async () => {
   await it('should throw for OCPP 2.0 without EVSE configuration', () => {
     // Arrange
     const { station: stationOcpp20 } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       connectorsCount: 0, // Ensure no EVSEs are created
       evseConfiguration: { evsesCount: 0 },
       stationInfo: {
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
         chargingStationId: getChargingStationId(1, chargingStationTemplate),
         hashId: getHashId(1, chargingStationTemplate),
         maximumAmperage: 16,
@@ -391,9 +393,7 @@ await describe('Helpers', async () => {
         ocppVersion: OCPPVersion.VERSION_20,
         templateIndex: 1,
         templateName: 'test-template.json',
-        TEST_CHARGING_STATION_BASE_NAME,
       },
-      TEST_CHARGING_STATION_BASE_NAME,
     })
 
     // Act & Assert
@@ -409,9 +409,11 @@ await describe('Helpers', async () => {
   await it('should throw for OCPP 2.0.1 without EVSE configuration', () => {
     // Arrange
     const { station: stationOcpp201 } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       connectorsCount: 0, // Ensure no EVSEs are created
       evseConfiguration: { evsesCount: 0 },
       stationInfo: {
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
         chargingStationId: getChargingStationId(1, chargingStationTemplate),
         hashId: getHashId(1, chargingStationTemplate),
         maximumAmperage: 16,
@@ -419,9 +421,7 @@ await describe('Helpers', async () => {
         ocppVersion: OCPPVersion.VERSION_201,
         templateIndex: 1,
         templateName: 'test-template.json',
-        TEST_CHARGING_STATION_BASE_NAME,
       },
-      TEST_CHARGING_STATION_BASE_NAME,
     })
 
     // Act & Assert
@@ -438,9 +438,9 @@ await describe('Helpers', async () => {
     // Arrange
     const warnMock = t.mock.method(logger, 'warn')
     const { station: stationNotStarted } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       started: false,
       starting: false,
-      TEST_CHARGING_STATION_BASE_NAME,
     })
 
     // Act
@@ -455,9 +455,9 @@ await describe('Helpers', async () => {
     // Arrange
     const warnMock = t.mock.method(logger, 'warn')
     const { station: stationStarting } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       started: false,
       starting: true,
-      TEST_CHARGING_STATION_BASE_NAME,
     })
 
     // Act
@@ -472,9 +472,9 @@ await describe('Helpers', async () => {
     // Arrange
     const warnMock = t.mock.method(logger, 'warn')
     const { station: stationStarted } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       started: true,
       starting: false,
-      TEST_CHARGING_STATION_BASE_NAME,
     })
 
     // Act
@@ -560,8 +560,8 @@ await describe('Helpers', async () => {
   await it('should return Available when no bootStatus is defined', () => {
     // Arrange
     const { station: chargingStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       connectorsCount: 2,
-      TEST_CHARGING_STATION_BASE_NAME,
     })
     const connectorStatus = {} as ConnectorStatus
 
@@ -574,8 +574,8 @@ await describe('Helpers', async () => {
   await it('should return bootStatus from template when defined', () => {
     // Arrange
     const { station: chargingStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       connectorsCount: 2,
-      TEST_CHARGING_STATION_BASE_NAME,
     })
     const connectorStatus = {
       bootStatus: ConnectorStatusEnum.Unavailable,
@@ -590,9 +590,9 @@ await describe('Helpers', async () => {
   await it('should return Unavailable when charging station is inoperative', () => {
     // Arrange
     const { station: chargingStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       connectorDefaults: { availability: AvailabilityType.Inoperative },
       connectorsCount: 2,
-      TEST_CHARGING_STATION_BASE_NAME,
     })
     const connectorStatus = {
       bootStatus: ConnectorStatusEnum.Available,
@@ -607,9 +607,9 @@ await describe('Helpers', async () => {
   await it('should return Unavailable when connector is inoperative', () => {
     // Arrange
     const { station: chargingStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       connectorDefaults: { availability: AvailabilityType.Inoperative },
       connectorsCount: 2,
-      TEST_CHARGING_STATION_BASE_NAME,
     })
     const connectorStatus = {
       availability: AvailabilityType.Inoperative,
@@ -625,8 +625,8 @@ await describe('Helpers', async () => {
   await it('should restore previous status when transaction is in progress', () => {
     // Arrange
     const { station: chargingStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       connectorsCount: 2,
-      TEST_CHARGING_STATION_BASE_NAME,
     })
     const connectorStatus = {
       bootStatus: ConnectorStatusEnum.Available,
@@ -643,8 +643,8 @@ await describe('Helpers', async () => {
   await it('should use bootStatus over previous status when no transaction', () => {
     // Arrange
     const { station: chargingStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       connectorsCount: 2,
-      TEST_CHARGING_STATION_BASE_NAME,
     })
     const connectorStatus = {
       bootStatus: ConnectorStatusEnum.Available,
@@ -684,8 +684,8 @@ await describe('Helpers', async () => {
 
   await it('should return false when no reservations exist (connector mode)', () => {
     const { station: chargingStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       connectorsCount: 2,
-      TEST_CHARGING_STATION_BASE_NAME,
     })
     expect(hasPendingReservations(chargingStation)).toBe(false)
   })
@@ -693,8 +693,8 @@ await describe('Helpers', async () => {
   await it('should return true when pending reservation exists (connector mode)', () => {
     // Arrange
     const { station: chargingStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       connectorsCount: 2,
-      TEST_CHARGING_STATION_BASE_NAME,
     })
     const connectorStatus = chargingStation.connectors.get(1)
     if (connectorStatus != null) {
@@ -708,9 +708,9 @@ await describe('Helpers', async () => {
   await it('should return false when no reservations exist (EVSE mode)', () => {
     // Arrange
     const { station: chargingStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       connectorsCount: 2,
       stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
-      TEST_CHARGING_STATION_BASE_NAME,
     })
 
     // Act & Assert
@@ -720,9 +720,9 @@ await describe('Helpers', async () => {
   await it('should return true when pending reservation exists (EVSE mode)', () => {
     // Arrange
     const { station: chargingStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       connectorsCount: 2,
       stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
-      TEST_CHARGING_STATION_BASE_NAME,
     })
     const firstEvse = chargingStation.evses.get(1)
     const firstConnector = firstEvse?.connectors.values().next().value
@@ -737,9 +737,9 @@ await describe('Helpers', async () => {
   await it('should return false when only expired reservations exist (EVSE mode)', () => {
     // Arrange
     const { station: chargingStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
       connectorsCount: 2,
       stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
-      TEST_CHARGING_STATION_BASE_NAME,
     })
     const firstEvse = chargingStation.evses.get(1)
     const firstConnector = firstEvse?.connectors.values().next().value
index 35f4ea1ca3228599aca61e37140fbdffba33d61b..3622e790305cc92fbbcb62a3395f5db56e9a50c8 100644 (file)
@@ -11,6 +11,7 @@ import type {
   ChargingStationTemplate,
   ConnectorStatus,
   EvseStatus,
+  Reservation,
   StopTransactionReason,
 } from '../../../src/types/index.js'
 
@@ -62,20 +63,11 @@ export interface CreateConnectorStatusOptions {
 }
 
 /**
- * Extended ChargingStation interface for test mocking.
- * Combines all test-specific properties to avoid multiple interface definitions.
- * - Timer properties: for cleanup (wsPingSetInterval, flushMessageBufferSetInterval)
- * - Reset methods: for Reset command tests (getNumberOfRunningTransactions, reset)
+ * Mock type combining ChargingStation with optional test-specific properties.
  */
-export interface MockChargingStation extends ChargingStation {
-  /** Private message buffer flush interval timer (accessed for cleanup) */
-  flushMessageBufferSetInterval?: NodeJS.Timeout
-  /** Mock method for getting number of running transactions (Reset tests) */
+export type MockChargingStation = ChargingStation & {
   getNumberOfRunningTransactions?: () => number
-  /** Mock method for reset operation (Reset tests) */
   reset?: () => Promise<void>
-  /** Private WebSocket ping interval timer (accessed for cleanup) */
-  wsPingSetInterval?: NodeJS.Timeout
 }
 
 /**
@@ -167,9 +159,9 @@ export interface MockOCPPIncomingRequestService {
  * Provides typed access to mock handlers without eslint-disable comments
  */
 export interface MockOCPPRequestService {
-  requestHandler: () => Promise<unknown>
-  sendError: () => Promise<unknown>
-  sendResponse: () => Promise<unknown>
+  requestHandler: (...args: unknown[]) => Promise<unknown>
+  sendError: (...args: unknown[]) => Promise<unknown>
+  sendResponse: (...args: unknown[]) => Promise<unknown>
 }
 
 /**
@@ -192,17 +184,20 @@ export function cleanupChargingStation (station: ChargingStation): void {
     station.heartbeatSetInterval = undefined
   }
 
-  // Stop WebSocket ping timer (private, accessed for cleanup via MockChargingStation)
-  const stationInternal = station as MockChargingStation
-  if (stationInternal.wsPingSetInterval != null) {
-    clearInterval(stationInternal.wsPingSetInterval)
-    stationInternal.wsPingSetInterval = undefined
+  // Stop WebSocket ping timer (private, accessed for cleanup via typed cast)
+  const stationWithWsTimer = station as unknown as { wsPingSetInterval?: NodeJS.Timeout }
+  if (stationWithWsTimer.wsPingSetInterval != null) {
+    clearInterval(stationWithWsTimer.wsPingSetInterval)
+    stationWithWsTimer.wsPingSetInterval = undefined
   }
 
-  // Stop message buffer flush timer (private, accessed for cleanup via MockChargingStation)
-  if (stationInternal.flushMessageBufferSetInterval != null) {
-    clearInterval(stationInternal.flushMessageBufferSetInterval)
-    stationInternal.flushMessageBufferSetInterval = undefined
+  // Stop message buffer flush timer (private, accessed for cleanup via typed cast)
+  const stationWithFlushTimer = station as unknown as {
+    flushMessageBufferSetInterval?: NodeJS.Timeout
+  }
+  if (stationWithFlushTimer.flushMessageBufferSetInterval != null) {
+    clearInterval(stationWithFlushTimer.flushMessageBufferSetInterval)
+    stationWithFlushTimer.flushMessageBufferSetInterval = undefined
   }
 
   // Close WebSocket connection
@@ -420,7 +415,7 @@ export function createMockChargingStation (
       }
       const connectorStatus = this.getConnectorStatus(reservation.connectorId as number)
       if (connectorStatus != null) {
-        connectorStatus.reservation = reservation
+        connectorStatus.reservation = reservation as unknown as Reservation
       }
     },
     automaticTransactionGenerator: undefined,
@@ -430,7 +425,13 @@ export function createMockChargingStation (
       currentTime: new Date(),
       interval: heartbeatInterval,
       status: bootNotificationStatus,
-    },
+    } as
+      | undefined
+      | {
+        currentTime: Date
+        interval: number
+        status: RegistrationStatusEnumType
+      },
 
     bufferMessage (message: string): void {
       this.messageQueue.push(message)
@@ -650,21 +651,20 @@ export function createMockChargingStation (
     idTagsCache: mockIdTagsCache as unknown,
 
     inAcceptedState (): boolean {
-      return this.bootNotificationResponse.status === RegistrationStatusEnumType.ACCEPTED
+      return this.bootNotificationResponse?.status === RegistrationStatusEnumType.ACCEPTED
     },
 
     // Core properties
     index,
 
     inPendingState (): boolean {
-      return this.bootNotificationResponse.status === RegistrationStatusEnumType.PENDING
+      return this.bootNotificationResponse?.status === RegistrationStatusEnumType.PENDING
     },
     inRejectedState (): boolean {
-      return this.bootNotificationResponse.status === RegistrationStatusEnumType.REJECTED
+      return this.bootNotificationResponse?.status === RegistrationStatusEnumType.REJECTED
     },
 
     inUnknownState (): boolean {
-      // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
       return this.bootNotificationResponse?.status == null
     },
 
@@ -840,7 +840,7 @@ export function createMockChargingStation (
         // Simulate async stop behavior (immediate resolution for tests)
         await Promise.resolve()
         this.closeWSConnection()
-        delete this.bootNotificationResponse
+        this.bootNotificationResponse = undefined
         this.started = false
         this.stopping = false
       }
index 7b3b44098ec5e0b0019687873621d2a30a444f23..8d4f6c8cfaaa568c89b711e495f9e94c4ac50b4d 100644 (file)
@@ -25,7 +25,7 @@ const TEST_STATION_HASH_ID = 'test-station-hash-12345'
 const TEST_CERT_TYPE = InstallCertificateUseEnumType.CSMSRootCertificate
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars -- kept for future assertions
-const _EXPECTED_HASH_DATA: CertificateHashDataType = {
+const _EXPECTED_HASH_DATA = {
   hashAlgorithm: HashAlgorithmEnumType.SHA256,
   issuerKeyHash: expect.stringMatching(/^[a-fA-F0-9]+$/),
   issuerNameHash: expect.stringMatching(/^[a-fA-F0-9]+$/),
index 4416d5b51d137e203e32ccadd0f0fb7062869ecd..bf936d4f9bdb68749750559f7d2554021ae95bd3 100644 (file)
@@ -7,6 +7,7 @@ import { expect } from '@std/expect'
 import { afterEach, beforeEach, describe, it, mock } from 'node:test'
 
 import type { ChargingStation } from '../../../../src/charging-station/index.js'
+import type { ChargingStationWithCertificateManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20CertificateManager.js'
 
 import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
 import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
@@ -26,10 +27,14 @@ import {
   VALID_CERTIFICATE_CHAIN,
   VALID_PEM_CERTIFICATE,
 } from './OCPP20CertificateTestData.js'
-import { createMockCertificateManager } from './OCPP20TestUtils.js'
+import {
+  createMockCertificateManager,
+  createStationWithCertificateManager,
+} from './OCPP20TestUtils.js'
 
 await describe('I04 - CertificateSigned', async () => {
   let station: ChargingStation
+  let stationWithCertManager: ChargingStationWithCertificateManager
   let incomingRequestService: OCPP20IncomingRequestService
   let testableService: ReturnType<typeof createTestableIncomingRequestService>
 
@@ -46,7 +51,10 @@ await describe('I04 - CertificateSigned', async () => {
       websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
     })
     station = mockStation
-    station.certificateManager = createMockCertificateManager()
+    stationWithCertManager = createStationWithCertificateManager(
+      station,
+      createMockCertificateManager()
+    )
     station.closeWSConnection = mock.fn()
     incomingRequestService = new OCPP20IncomingRequestService()
     testableService = createTestableIncomingRequestService(incomingRequestService)
@@ -57,7 +65,7 @@ await describe('I04 - CertificateSigned', async () => {
   })
   await describe('Valid Certificate Chain Installation', async () => {
     await it('should accept valid certificate chain', async () => {
-      station.certificateManager = createMockCertificateManager({
+      stationWithCertManager.certificateManager = createMockCertificateManager({
         storeCertificateResult: true,
       })
 
@@ -77,7 +85,7 @@ await describe('I04 - CertificateSigned', async () => {
     })
 
     await it('should accept single certificate (no chain)', async () => {
-      station.certificateManager = createMockCertificateManager({
+      stationWithCertManager.certificateManager = createMockCertificateManager({
         storeCertificateResult: true,
       })
 
@@ -118,7 +126,7 @@ await describe('I04 - CertificateSigned', async () => {
       const mockCertManager = createMockCertificateManager({
         storeCertificateResult: true,
       })
-      station.certificateManager = mockCertManager
+      stationWithCertManager.certificateManager = mockCertManager
       const mockCloseWSConnection = mock.fn()
       station.closeWSConnection = mockCloseWSConnection
 
@@ -141,7 +149,7 @@ await describe('I04 - CertificateSigned', async () => {
       const mockCertManager = createMockCertificateManager({
         storeCertificateResult: true,
       })
-      station.certificateManager = mockCertManager
+      stationWithCertManager.certificateManager = mockCertManager
       const mockCloseWSConnection = mock.fn()
       station.closeWSConnection = mockCloseWSConnection
 
@@ -176,8 +184,7 @@ await describe('I04 - CertificateSigned', async () => {
         websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
       })
 
-      // Ensure certificateManager is undefined (not present)
-      delete stationWithoutCertManager.certificateManager
+      // certificateManager is not set on this station (not present by default)
 
       const request: OCPP20CertificateSignedRequest = {
         certificateChain: VALID_PEM_CERTIFICATE,
@@ -196,7 +203,7 @@ await describe('I04 - CertificateSigned', async () => {
 
   await describe('Storage Failure Handling', async () => {
     await it('should return Rejected status when storage fails', async () => {
-      station.certificateManager = createMockCertificateManager({
+      stationWithCertManager.certificateManager = createMockCertificateManager({
         storeCertificateResult: false,
       })
 
@@ -215,7 +222,7 @@ await describe('I04 - CertificateSigned', async () => {
     })
 
     await it('should return Rejected status when storage throws error', async () => {
-      station.certificateManager = createMockCertificateManager({
+      stationWithCertManager.certificateManager = createMockCertificateManager({
         storeCertificateError: new Error('Storage full'),
       })
 
@@ -236,7 +243,7 @@ await describe('I04 - CertificateSigned', async () => {
 
   await describe('Response Structure Validation', async () => {
     await it('should return response matching CertificateSignedResponse schema', async () => {
-      station.certificateManager = createMockCertificateManager({
+      stationWithCertManager.certificateManager = createMockCertificateManager({
         storeCertificateResult: true,
       })
 
index 5d0317b03c8fcc0118ea5576cc9e768e83246880..9a160c69d867fde5bf21d01890dc1f3d51c9e943 100644 (file)
@@ -78,7 +78,7 @@ await describe('I04 - DeleteCertificate', async () => {
   await describe('Valid Certificate Deletion', async () => {
     await it('should accept deletion of existing certificate', async () => {
       stationWithCertManager.certificateManager = createMockCertificateManager({
-        deleteCertificateResult: { status: 'Accepted' },
+        deleteCertificateResult: { status: DeleteCertificateStatusEnumType.Accepted },
       })
 
       const request: OCPP20DeleteCertificateRequest = {
@@ -98,7 +98,7 @@ await describe('I04 - DeleteCertificate', async () => {
 
     await it('should accept deletion with SHA384 hash algorithm', async () => {
       stationWithCertManager.certificateManager = createMockCertificateManager({
-        deleteCertificateResult: { status: 'Accepted' },
+        deleteCertificateResult: { status: DeleteCertificateStatusEnumType.Accepted },
       })
 
       const request: OCPP20DeleteCertificateRequest = {
@@ -118,7 +118,7 @@ await describe('I04 - DeleteCertificate', async () => {
 
     await it('should accept deletion with SHA512 hash algorithm', async () => {
       stationWithCertManager.certificateManager = createMockCertificateManager({
-        deleteCertificateResult: { status: 'Accepted' },
+        deleteCertificateResult: { status: DeleteCertificateStatusEnumType.Accepted },
       })
 
       const request: OCPP20DeleteCertificateRequest = {
@@ -140,7 +140,7 @@ await describe('I04 - DeleteCertificate', async () => {
   await describe('Certificate Not Found', async () => {
     await it('should return NotFound for non-existent certificate', async () => {
       stationWithCertManager.certificateManager = createMockCertificateManager({
-        deleteCertificateResult: { status: 'NotFound' },
+        deleteCertificateResult: { status: DeleteCertificateStatusEnumType.NotFound },
       })
 
       const request: OCPP20DeleteCertificateRequest = {
@@ -209,7 +209,7 @@ await describe('I04 - DeleteCertificate', async () => {
   await describe('Response Structure Validation', async () => {
     await it('should return response matching DeleteCertificateResponse schema', async () => {
       stationWithCertManager.certificateManager = createMockCertificateManager({
-        deleteCertificateResult: { status: 'Accepted' },
+        deleteCertificateResult: { status: DeleteCertificateStatusEnumType.Accepted },
       })
 
       const request: OCPP20DeleteCertificateRequest = {
index 9ff24a46854c5e21c0ff9ad6a8326dfa4c6fcfd0..980653d84b673868fa58d3ff7d82beb12c600327 100644 (file)
@@ -36,7 +36,7 @@ import {
 } from './OCPP20TestUtils.js'
 
 await describe('B06 - Get Variables', async () => {
-  let station: ReturnType<typeof createMockChargingStation>
+  let station: ReturnType<typeof createMockChargingStation>['station']
   let incomingRequestService: OCPP20IncomingRequestService
 
   beforeEach(() => {
index ff1ae48beecf6ddc7b58762aa15b042409f4f858..8a5ee14d0d1080185c0070564382d433f900e76b 100644 (file)
@@ -164,7 +164,7 @@ await describe('I03 - InstallCertificate', async () => {
       stationWithCertManager.certificateManager = createMockCertificateManager({
         storeCertificateResult: false,
       })
-      mockStation.stationInfo.validateCertificateExpiry = true
+      ;(mockStation.stationInfo as Record<string, unknown>).validateCertificateExpiry = true
 
       const request: OCPP20InstallCertificateRequest = {
         certificate: EXPIRED_PEM_CERTIFICATE,
@@ -179,7 +179,7 @@ await describe('I03 - InstallCertificate', async () => {
       expect(response.statusInfo).toBeDefined()
       expect(response.statusInfo?.reasonCode).toBeDefined()
 
-      delete mockStation.stationInfo.validateCertificateExpiry
+      delete (mockStation.stationInfo as Record<string, unknown>).validateCertificateExpiry
     })
   })
 
index f8c4ba4bd40f8ba03abaf68e2c4bd75776b5ec4c..d06d9e5f43ed8218890a34b2e324fb845396334d 100644 (file)
@@ -4,6 +4,7 @@
  */
 
 import { expect } from '@std/expect'
+import assert from 'node:assert'
 import { afterEach, beforeEach, describe, it } from 'node:test'
 
 import type { ChargingStation } from '../../../../src/charging-station/ChargingStation.js'
@@ -141,6 +142,7 @@ await describe('G03 - Remote Start Pre-Authorization', async () => {
     })
 
     await it('should not modify connector status before authorization', () => {
+      assert(mockStation != null)
       // Given: Connector in initial state
       // Then: Connector status should remain unchanged before processing
       const connectorStatus = mockStation.getConnectorStatus(1)
@@ -233,6 +235,7 @@ await describe('G03 - Remote Start Pre-Authorization', async () => {
 
   await describe('G03.FR.03.005 - Remote start on occupied connector', async () => {
     await it('should detect existing transaction on connector', () => {
+      assert(mockStation != null)
       // Given: Connector with active transaction
       mockStation.getConnectorStatus = (): ConnectorStatus => ({
         availability: OperationalStatusEnumType.Operative,
@@ -253,6 +256,7 @@ await describe('G03 - Remote Start Pre-Authorization', async () => {
     })
 
     await it('should preserve existing transaction details', () => {
+      assert(mockStation != null)
       // Given: Existing transaction details
       const existingTransactionId = 'existing-tx-456'
       const existingTokenTag = 'EXISTING_TOKEN_002'
@@ -353,6 +357,7 @@ await describe('G03 - Remote Start Pre-Authorization', async () => {
     })
 
     await it('should support OCPP 2.0.1 version', () => {
+      assert(mockStation != null)
       // Given: Station with OCPP 2.0.1
       expect(mockStation.stationInfo?.ocppVersion).toBe(OCPPVersion.VERSION_201)
     })
@@ -405,6 +410,7 @@ await describe('G03 - Remote Start Pre-Authorization', async () => {
     })
 
     await it('should have valid charging station configuration', () => {
+      assert(mockStation != null)
       // Then: Charging station should have required configuration
       expect(mockStation).toBeDefined()
       expect(mockStation.evses).toBeDefined()
index 2631febb22382d74f48896291a4e4c499318f895..e4ceb85c7dea9317676aa03fa454788c62c6549b 100644 (file)
@@ -9,7 +9,7 @@ import type { ChargingStation } from '../../../../src/charging-station/index.js'
 import type { OCPP20RequestStartTransactionRequest } from '../../../../src/types/index.js'
 import type {
   OCPP20ChargingProfileType,
-  OCPP20ChargingRateUnitType,
+  OCPP20ChargingRateUnitEnumType,
 } from '../../../../src/types/ocpp/2.0/Transaction.js'
 
 import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
@@ -165,8 +165,7 @@ await describe('F01 & F02 - Remote Start Transaction', async () => {
       chargingProfilePurpose: OCPP20ChargingProfilePurposeEnumType.TxProfile,
       chargingSchedule: [
         {
-          // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
-          chargingRateUnit: 'A' as OCPP20ChargingRateUnitType,
+          chargingRateUnit: 'A' as OCPP20ChargingRateUnitEnumType,
           chargingSchedulePeriod: [
             {
               limit: 30,
@@ -207,8 +206,7 @@ await describe('F01 & F02 - Remote Start Transaction', async () => {
       chargingProfilePurpose: OCPP20ChargingProfilePurposeEnumType.TxDefaultProfile,
       chargingSchedule: [
         {
-          // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
-          chargingRateUnit: 'A' as OCPP20ChargingRateUnitType,
+          chargingRateUnit: 'A' as OCPP20ChargingRateUnitEnumType,
           chargingSchedulePeriod: [
             {
               limit: 25,
@@ -248,8 +246,7 @@ await describe('F01 & F02 - Remote Start Transaction', async () => {
       chargingProfilePurpose: OCPP20ChargingProfilePurposeEnumType.TxProfile,
       chargingSchedule: [
         {
-          // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
-          chargingRateUnit: 'A' as OCPP20ChargingRateUnitType,
+          chargingRateUnit: 'A' as OCPP20ChargingRateUnitEnumType,
           chargingSchedulePeriod: [
             {
               limit: 32,
index e95ef7d8ba5f93dffd9a31a8de8150cea76f1468..56f72e43380e56fe9b0dc6d9422e484e4accd9e3 100644 (file)
@@ -16,8 +16,10 @@ import type { MockChargingStation } from '../../ChargingStationTestUtils.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 { VARIABLE_REGISTRY } from '../../../../src/charging-station/ocpp/2.0/OCPP20VariableRegistry.js'
 import {
   FirmwareStatus,
+  OCPP20ComponentName,
   ReasonCodeEnumType,
   ResetEnumType,
   ResetStatusEnumType,
@@ -324,10 +326,11 @@ await describe('B11 & B12 - Reset', async () => {
       await describe('Firmware Update Blocking', async () => {
         // FR: B12.FR.04.01 - Station NOT idle during firmware operations
 
-        await it('should return Scheduled when firmware is Downloading', async () => {
+        await it('should return Rejected/FwUpdateInProgress when firmware is Downloading', async () => {
           const station = createTestStation()
-          // Firmware status: Downloading
-          Object.assign(station.stationInfo, {
+          // Firmware check runs before OnIdle idle-state logic — always returns Rejected
+          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+          Object.assign(station.stationInfo!, {
             firmwareStatus: FirmwareStatus.Downloading,
           })
 
@@ -341,13 +344,15 @@ await describe('B11 & B12 - Reset', async () => {
           )
 
           expect(response).toBeDefined()
-          expect(response.status).toBe(ResetStatusEnumType.Scheduled)
+          expect(response.status).toBe(ResetStatusEnumType.Rejected)
+          expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.FwUpdateInProgress)
         })
 
-        await it('should return Scheduled when firmware is Downloaded', async () => {
+        await it('should return Rejected/FwUpdateInProgress when firmware is Downloaded', async () => {
           const station = createTestStation()
-          // Firmware status: Downloaded
-          Object.assign(station.stationInfo, {
+          // Firmware check runs before OnIdle idle-state logic — always returns Rejected
+          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+          Object.assign(station.stationInfo!, {
             firmwareStatus: FirmwareStatus.Downloaded,
           })
 
@@ -361,13 +366,15 @@ await describe('B11 & B12 - Reset', async () => {
           )
 
           expect(response).toBeDefined()
-          expect(response.status).toBe(ResetStatusEnumType.Scheduled)
+          expect(response.status).toBe(ResetStatusEnumType.Rejected)
+          expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.FwUpdateInProgress)
         })
 
-        await it('should return Scheduled when firmware is Installing', async () => {
+        await it('should return Rejected/FwUpdateInProgress when firmware is Installing', async () => {
           const station = createTestStation()
-          // Firmware status: Installing
-          Object.assign(station.stationInfo, {
+          // Firmware check runs before OnIdle idle-state logic — always returns Rejected
+          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+          Object.assign(station.stationInfo!, {
             firmwareStatus: FirmwareStatus.Installing,
           })
 
@@ -381,13 +388,15 @@ await describe('B11 & B12 - Reset', async () => {
           )
 
           expect(response).toBeDefined()
-          expect(response.status).toBe(ResetStatusEnumType.Scheduled)
+          expect(response.status).toBe(ResetStatusEnumType.Rejected)
+          expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.FwUpdateInProgress)
         })
 
         await it('should return Accepted when firmware is Installed (complete)', async () => {
           const station = createTestStation()
           // Firmware status: Installed (complete)
-          Object.assign(station.stationInfo, {
+          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+          Object.assign(station.stationInfo!, {
             firmwareStatus: FirmwareStatus.Installed,
           })
 
@@ -407,7 +416,8 @@ await describe('B11 & B12 - Reset', async () => {
         await it('should return Accepted when firmware status is Idle', async () => {
           const station = createTestStation()
           // Firmware status: Idle
-          Object.assign(station.stationInfo, {
+          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+          Object.assign(station.stationInfo!, {
             firmwareStatus: FirmwareStatus.Idle,
           })
 
@@ -521,7 +531,8 @@ await describe('B11 & B12 - Reset', async () => {
           // No transactions
           station.getNumberOfRunningTransactions = () => 0
           // No firmware update
-          Object.assign(station.stationInfo, {
+          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+          Object.assign(station.stationInfo!, {
             firmwareStatus: FirmwareStatus.Idle,
           })
           // No reservations (default)
@@ -543,10 +554,6 @@ await describe('B11 & B12 - Reset', async () => {
           const station = createTestStation()
           // Transaction active
           station.getNumberOfRunningTransactions = () => 1
-          // Firmware update in progress
-          Object.assign(station.stationInfo, {
-            firmwareStatus: FirmwareStatus.Downloading,
-          })
           // Non-expired reservation
           const futureExpiryDate = new Date(Date.now() + TEST_ONE_HOUR_MS)
           const mockReservation: Partial<Reservation> = {
@@ -578,4 +585,42 @@ await describe('B11 & B12 - Reset', async () => {
       })
     })
   })
+
+  await describe('AllowReset variable checks', async () => {
+    const ALLOW_RESET_KEY = `${OCPP20ComponentName.EVSE as string}::AllowReset`
+    let savedDefaultValue: string | undefined
+
+    beforeEach(() => {
+      savedDefaultValue = VARIABLE_REGISTRY[ALLOW_RESET_KEY].defaultValue
+    })
+
+    afterEach(() => {
+      VARIABLE_REGISTRY[ALLOW_RESET_KEY].defaultValue = savedDefaultValue
+    })
+
+    await it('should reject with NotEnabled when AllowReset is false', async () => {
+      const station = ResetTestFixtures.createStandardStation()
+      VARIABLE_REGISTRY[ALLOW_RESET_KEY].defaultValue = 'false'
+      const request: OCPP20ResetRequest = { type: ResetEnumType.Immediate }
+      const response = await testableService.handleRequestReset(station, request)
+      expect(response.status).toBe(ResetStatusEnumType.Rejected)
+      expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.NotEnabled)
+    })
+
+    await it('should proceed normally when AllowReset is true', async () => {
+      const station = ResetTestFixtures.createStandardStation()
+      VARIABLE_REGISTRY[ALLOW_RESET_KEY].defaultValue = 'true'
+      const request: OCPP20ResetRequest = { type: ResetEnumType.Immediate }
+      const response = await testableService.handleRequestReset(station, request)
+      expect(response.status).toBe(ResetStatusEnumType.Accepted)
+    })
+
+    await it('should proceed normally when AllowReset defaultValue is undefined', async () => {
+      const station = ResetTestFixtures.createStandardStation()
+      VARIABLE_REGISTRY[ALLOW_RESET_KEY].defaultValue = undefined
+      const request: OCPP20ResetRequest = { type: ResetEnumType.Immediate }
+      const response = await testableService.handleRequestReset(station, request)
+      expect(response.status).toBe(ResetStatusEnumType.Accepted)
+    })
+  })
 })
diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts
new file mode 100644 (file)
index 0000000..4cb765e
--- /dev/null
@@ -0,0 +1,359 @@
+/**
+ * @file Tests for OCPP20IncomingRequestService TriggerMessage
+ * @description Unit tests for OCPP 2.0 TriggerMessage command handling (F06)
+ */
+
+import { expect } from '@std/expect'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
+
+import type {
+  OCPP20TriggerMessageRequest,
+  OCPP20TriggerMessageResponse,
+} from '../../../../src/types/index.js'
+import type { MockChargingStation } from '../../ChargingStationTestUtils.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 {
+  MessageTriggerEnumType,
+  OCPPVersion,
+  ReasonCodeEnumType,
+  RegistrationStatusEnumType,
+  TriggerMessageStatusEnumType,
+} 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'
+
+/**
+ * Create a mock station suitable for TriggerMessage tests.
+ * Uses a mock requestHandler to avoid network calls from fire-and-forget paths.
+ * @returns The mock station and its request handler spy
+ */
+function createTriggerMessageStation (): {
+  mockStation: MockChargingStation
+  requestHandlerMock: ReturnType<typeof mock.fn>
+} {
+  const requestHandlerMock = mock.fn(async () => Promise.resolve({}))
+  const { station } = createMockChargingStation({
+    baseName: TEST_CHARGING_STATION_BASE_NAME,
+    connectorsCount: 3,
+    evseConfiguration: { evsesCount: 3 },
+    heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+    ocppRequestService: {
+      requestHandler: requestHandlerMock,
+    },
+    stationInfo: {
+      ocppVersion: OCPPVersion.VERSION_201,
+    },
+    websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+  })
+  const mockStation = station as MockChargingStation
+  return { mockStation, requestHandlerMock }
+}
+
+await describe('F06 - TriggerMessage', async () => {
+  let incomingRequestService: OCPP20IncomingRequestService
+  let testableService: ReturnType<typeof createTestableIncomingRequestService>
+
+  beforeEach(() => {
+    mock.timers.enable({ apis: ['setInterval', 'setTimeout'] })
+    incomingRequestService = new OCPP20IncomingRequestService()
+    testableService = createTestableIncomingRequestService(incomingRequestService)
+  })
+
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await describe('F06 - Accepted triggers (happy path)', async () => {
+    let mockStation: MockChargingStation
+
+    beforeEach(() => {
+      ;({ mockStation } = createTriggerMessageStation())
+    })
+
+    await it('should return Accepted for BootNotification trigger when boot is Pending', () => {
+      if (mockStation.bootNotificationResponse != null) {
+        mockStation.bootNotificationResponse.status = RegistrationStatusEnumType.PENDING
+      }
+
+      const request: OCPP20TriggerMessageRequest = {
+        requestedMessage: MessageTriggerEnumType.BootNotification,
+      }
+
+      const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+        mockStation,
+        request
+      )
+
+      expect(response.status).toBe(TriggerMessageStatusEnumType.Accepted)
+      expect(response.statusInfo).toBeUndefined()
+    })
+
+    await it('should return Accepted for Heartbeat trigger', () => {
+      const request: OCPP20TriggerMessageRequest = {
+        requestedMessage: MessageTriggerEnumType.Heartbeat,
+      }
+
+      const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+        mockStation,
+        request
+      )
+
+      expect(response.status).toBe(TriggerMessageStatusEnumType.Accepted)
+      expect(response.statusInfo).toBeUndefined()
+    })
+
+    await it('should return Accepted for StatusNotification trigger without EVSE', () => {
+      const request: OCPP20TriggerMessageRequest = {
+        requestedMessage: MessageTriggerEnumType.StatusNotification,
+      }
+
+      const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+        mockStation,
+        request
+      )
+
+      expect(response.status).toBe(TriggerMessageStatusEnumType.Accepted)
+      expect(response.statusInfo).toBeUndefined()
+    })
+
+    await it('should return Accepted for StatusNotification trigger with valid EVSE and connector', () => {
+      const request: OCPP20TriggerMessageRequest = {
+        evse: { connectorId: 1, id: 1 },
+        requestedMessage: MessageTriggerEnumType.StatusNotification,
+      }
+
+      const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+        mockStation,
+        request
+      )
+
+      expect(response.status).toBe(TriggerMessageStatusEnumType.Accepted)
+      expect(response.statusInfo).toBeUndefined()
+    })
+
+    await it('should not validate EVSE when evse.id is 0', () => {
+      // evse.id === 0 means whole-station scope; EVSE validation is skipped
+      const request: OCPP20TriggerMessageRequest = {
+        evse: { id: 0 },
+        requestedMessage: MessageTriggerEnumType.Heartbeat,
+      }
+
+      const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+        mockStation,
+        request
+      )
+
+      expect(response.status).toBe(TriggerMessageStatusEnumType.Accepted)
+    })
+  })
+
+  await describe('F06 - NotImplemented triggers', async () => {
+    let mockStation: MockChargingStation
+
+    beforeEach(() => {
+      ;({ mockStation } = createTriggerMessageStation())
+    })
+
+    await it('should return NotImplemented for MeterValues trigger', () => {
+      const request: OCPP20TriggerMessageRequest = {
+        requestedMessage: MessageTriggerEnumType.MeterValues,
+      }
+
+      const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+        mockStation,
+        request
+      )
+
+      expect(response.status).toBe(TriggerMessageStatusEnumType.NotImplemented)
+      expect(response.statusInfo).toBeDefined()
+      expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnsupportedRequest)
+      expect(response.statusInfo?.additionalInfo).toContain('MeterValues')
+    })
+
+    await it('should return NotImplemented for TransactionEvent trigger', () => {
+      const request: OCPP20TriggerMessageRequest = {
+        requestedMessage: MessageTriggerEnumType.TransactionEvent,
+      }
+
+      const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+        mockStation,
+        request
+      )
+
+      expect(response.status).toBe(TriggerMessageStatusEnumType.NotImplemented)
+      expect(response.statusInfo).toBeDefined()
+      expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnsupportedRequest)
+    })
+
+    await it('should return NotImplemented for LogStatusNotification trigger', () => {
+      const request: OCPP20TriggerMessageRequest = {
+        requestedMessage: MessageTriggerEnumType.LogStatusNotification,
+      }
+
+      const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+        mockStation,
+        request
+      )
+
+      expect(response.status).toBe(TriggerMessageStatusEnumType.NotImplemented)
+      expect(response.statusInfo).toBeDefined()
+      expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnsupportedRequest)
+    })
+
+    await it('should return NotImplemented for FirmwareStatusNotification trigger', () => {
+      const request: OCPP20TriggerMessageRequest = {
+        requestedMessage: MessageTriggerEnumType.FirmwareStatusNotification,
+      }
+
+      const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+        mockStation,
+        request
+      )
+
+      expect(response.status).toBe(TriggerMessageStatusEnumType.NotImplemented)
+      expect(response.statusInfo).toBeDefined()
+      expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnsupportedRequest)
+    })
+  })
+
+  await describe('F06 - EVSE validation', async () => {
+    let mockStation: MockChargingStation
+
+    beforeEach(() => {
+      ;({ mockStation } = createTriggerMessageStation())
+    })
+
+    await it('should return Rejected with UnsupportedRequest when station has no EVSEs and EVSE id > 0 specified', () => {
+      Object.defineProperty(mockStation, 'hasEvses', {
+        configurable: true,
+        value: false,
+        writable: true,
+      })
+
+      const request: OCPP20TriggerMessageRequest = {
+        evse: { id: 1 },
+        requestedMessage: MessageTriggerEnumType.BootNotification,
+      }
+
+      const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+        mockStation,
+        request
+      )
+
+      expect(response.status).toBe(TriggerMessageStatusEnumType.Rejected)
+      expect(response.statusInfo).toBeDefined()
+      expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnsupportedRequest)
+      expect(response.statusInfo?.additionalInfo).toContain('does not support EVSEs')
+    })
+
+    await it('should return Rejected with UnknownEvse for non-existent EVSE id', () => {
+      const request: OCPP20TriggerMessageRequest = {
+        evse: { id: 999 },
+        requestedMessage: MessageTriggerEnumType.BootNotification,
+      }
+
+      const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+        mockStation,
+        request
+      )
+
+      expect(response.status).toBe(TriggerMessageStatusEnumType.Rejected)
+      expect(response.statusInfo).toBeDefined()
+      expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnknownEvse)
+      expect(response.statusInfo?.additionalInfo).toContain('999')
+    })
+
+    await it('should accept trigger when evse is undefined', () => {
+      const request: OCPP20TriggerMessageRequest = {
+        requestedMessage: MessageTriggerEnumType.Heartbeat,
+      }
+
+      const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+        mockStation,
+        request
+      )
+
+      expect(response.status).toBe(TriggerMessageStatusEnumType.Accepted)
+    })
+  })
+
+  await describe('F06.FR.17 - BootNotification already accepted', async () => {
+    let mockStation: MockChargingStation
+
+    beforeEach(() => {
+      ;({ mockStation } = createTriggerMessageStation())
+    })
+
+    await it('should return Rejected when boot was already Accepted (F06.FR.17)', () => {
+      if (mockStation.bootNotificationResponse != null) {
+        mockStation.bootNotificationResponse.status = RegistrationStatusEnumType.ACCEPTED
+      }
+
+      const request: OCPP20TriggerMessageRequest = {
+        requestedMessage: MessageTriggerEnumType.BootNotification,
+      }
+
+      const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+        mockStation,
+        request
+      )
+
+      expect(response.status).toBe(TriggerMessageStatusEnumType.Rejected)
+      expect(response.statusInfo).toBeDefined()
+      expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.NotEnabled)
+      expect(response.statusInfo?.additionalInfo).toContain('F06.FR.17')
+    })
+
+    await it('should return Accepted for BootNotification when boot was Rejected', () => {
+      if (mockStation.bootNotificationResponse != null) {
+        mockStation.bootNotificationResponse.status = RegistrationStatusEnumType.REJECTED
+      }
+
+      const request: OCPP20TriggerMessageRequest = {
+        requestedMessage: MessageTriggerEnumType.BootNotification,
+      }
+
+      const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
+        mockStation,
+        request
+      )
+
+      expect(response.status).toBe(TriggerMessageStatusEnumType.Accepted)
+    })
+  })
+
+  await describe('F06 - Response structure', async () => {
+    let mockStation: MockChargingStation
+
+    beforeEach(() => {
+      ;({ mockStation } = createTriggerMessageStation())
+    })
+
+    await it('should return a plain object with a string status field', () => {
+      const request: OCPP20TriggerMessageRequest = {
+        requestedMessage: MessageTriggerEnumType.BootNotification,
+      }
+
+      const response = testableService.handleRequestTriggerMessage(mockStation, request)
+
+      expect(response).toBeDefined()
+      expect(typeof response).toBe('object')
+      expect(typeof response.status).toBe('string')
+    })
+
+    await it('handler is synchronous — result is not a Promise', () => {
+      const request: OCPP20TriggerMessageRequest = {
+        requestedMessage: MessageTriggerEnumType.BootNotification,
+      }
+
+      const result = testableService.handleRequestTriggerMessage(mockStation, request)
+
+      // A Promise would have a `then` property that is a function
+      expect(typeof (result as unknown as Promise<unknown>).then).not.toBe('function')
+    })
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UnlockConnector.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UnlockConnector.test.ts
new file mode 100644 (file)
index 0000000..5c39e73
--- /dev/null
@@ -0,0 +1,228 @@
+/**
+ * @file Tests for OCPP20IncomingRequestService UnlockConnector
+ * @description Unit tests for OCPP 2.0 UnlockConnector command handling (F05)
+ */
+
+import { expect } from '@std/expect'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
+
+import type {
+  OCPP20UnlockConnectorRequest,
+  OCPP20UnlockConnectorResponse,
+} from '../../../../src/types/index.js'
+import type { MockChargingStation } from '../../ChargingStationTestUtils.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,
+  UnlockStatusEnumType,
+} 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'
+
+/**
+ * Create a mock station suitable for UnlockConnector tests.
+ * Provides 3 EVSEs each with 1 connector.
+ * Mocks requestHandler to allow sendAndSetConnectorStatus to succeed
+ * (sendAndSetConnectorStatus calls requestHandler internally for StatusNotification).
+ * @returns The mock station and its request handler spy
+ */
+function createUnlockConnectorStation (): {
+  mockStation: MockChargingStation
+  requestHandlerMock: ReturnType<typeof mock.fn>
+} {
+  const requestHandlerMock = mock.fn(async () => Promise.resolve({}))
+  const { station } = createMockChargingStation({
+    baseName: TEST_CHARGING_STATION_BASE_NAME,
+    connectorsCount: 3,
+    evseConfiguration: { evsesCount: 3 },
+    heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+    ocppRequestService: {
+      requestHandler: requestHandlerMock,
+    },
+    stationInfo: {
+      ocppVersion: OCPPVersion.VERSION_201,
+    },
+    websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+  })
+  return { mockStation: station as MockChargingStation, requestHandlerMock }
+}
+
+await describe('F05 - UnlockConnector', async () => {
+  let incomingRequestService: OCPP20IncomingRequestService
+  let testableService: ReturnType<typeof createTestableIncomingRequestService>
+
+  beforeEach(() => {
+    mock.timers.enable({ apis: ['setInterval', 'setTimeout'] })
+    incomingRequestService = new OCPP20IncomingRequestService()
+    testableService = createTestableIncomingRequestService(incomingRequestService)
+  })
+
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await describe('F05 - No-EVSE station errors', async () => {
+    await it('should return UnknownConnector + UnsupportedRequest when station has no EVSEs', async () => {
+      const { mockStation } = createUnlockConnectorStation()
+      Object.defineProperty(mockStation, 'hasEvses', {
+        configurable: true,
+        value: false,
+        writable: true,
+      })
+
+      const request: OCPP20UnlockConnectorRequest = { connectorId: 1, evseId: 1 }
+      const response: OCPP20UnlockConnectorResponse =
+        await testableService.handleRequestUnlockConnector(mockStation, request)
+
+      expect(response.status).toBe(UnlockStatusEnumType.UnknownConnector)
+      expect(response.statusInfo).toBeDefined()
+      expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnsupportedRequest)
+      expect(response.statusInfo?.additionalInfo).toContain('does not support EVSEs')
+    })
+  })
+
+  await describe('F05 - Unknown EVSE errors', async () => {
+    await it('should return UnknownConnector + UnknownEvse for non-existent EVSE id', async () => {
+      const { mockStation } = createUnlockConnectorStation()
+
+      const request: OCPP20UnlockConnectorRequest = { connectorId: 1, evseId: 999 }
+      const response: OCPP20UnlockConnectorResponse =
+        await testableService.handleRequestUnlockConnector(mockStation, request)
+
+      expect(response.status).toBe(UnlockStatusEnumType.UnknownConnector)
+      expect(response.statusInfo).toBeDefined()
+      expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnknownEvse)
+      expect(response.statusInfo?.additionalInfo).toContain('999')
+    })
+  })
+
+  await describe('F05 - Unknown connector errors', async () => {
+    await it('should return UnknownConnector + UnknownConnectorId for non-existent connector on EVSE', async () => {
+      // With evsesCount:3 connectorsCount:3, EVSE 1 has connector 1 only
+      const { mockStation } = createUnlockConnectorStation()
+
+      const request: OCPP20UnlockConnectorRequest = { connectorId: 99, evseId: 1 }
+      const response: OCPP20UnlockConnectorResponse =
+        await testableService.handleRequestUnlockConnector(mockStation, request)
+
+      expect(response.status).toBe(UnlockStatusEnumType.UnknownConnector)
+      expect(response.statusInfo).toBeDefined()
+      expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.UnknownConnectorId)
+      expect(response.statusInfo?.additionalInfo).toContain('99')
+      expect(response.statusInfo?.additionalInfo).toContain('1')
+    })
+  })
+
+  await describe('F05 - Ongoing transaction errors (F05.FR.02)', async () => {
+    await it('should return OngoingAuthorizedTransaction when specified connector has active transaction', async () => {
+      const { mockStation } = createUnlockConnectorStation()
+
+      const evseStatus = mockStation.evses.get(1)
+      const connector = evseStatus?.connectors.get(1)
+      if (connector != null) {
+        connector.transactionId = 'tx-001'
+      }
+
+      const request: OCPP20UnlockConnectorRequest = { connectorId: 1, evseId: 1 }
+      const response: OCPP20UnlockConnectorResponse =
+        await testableService.handleRequestUnlockConnector(mockStation, request)
+
+      expect(response.status).toBe(UnlockStatusEnumType.OngoingAuthorizedTransaction)
+      expect(response.statusInfo).toBeDefined()
+      expect(response.statusInfo?.reasonCode).toBe(ReasonCodeEnumType.TxInProgress)
+      expect(response.statusInfo?.additionalInfo).toContain('1')
+    })
+
+    await it('should return Unlocked when a different connector on the same EVSE has a transaction (F05.FR.02)', async () => {
+      const requestHandlerMock = mock.fn(async () => Promise.resolve({}))
+      const { station } = createMockChargingStation({
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
+        connectorsCount: 2,
+        evseConfiguration: { evsesCount: 1 },
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        ocppRequestService: {
+          requestHandler: requestHandlerMock,
+        },
+        stationInfo: {
+          ocppVersion: OCPPVersion.VERSION_201,
+        },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+      const multiConnectorStation = station as MockChargingStation
+
+      const evseStatus = multiConnectorStation.evses.get(1)
+      const connector2 = evseStatus?.connectors.get(2)
+      if (connector2 != null) {
+        connector2.transactionId = 'tx-other'
+      }
+
+      const request: OCPP20UnlockConnectorRequest = { connectorId: 1, evseId: 1 }
+      const response: OCPP20UnlockConnectorResponse =
+        await testableService.handleRequestUnlockConnector(multiConnectorStation, request)
+
+      expect(response.status).toBe(UnlockStatusEnumType.Unlocked)
+    })
+  })
+
+  await describe('F05 - Happy path (unlock succeeds)', async () => {
+    await it('should return Unlocked when EVSE and connector exist and no active transaction', async () => {
+      const { mockStation } = createUnlockConnectorStation()
+
+      const request: OCPP20UnlockConnectorRequest = { connectorId: 1, evseId: 1 }
+      const response: OCPP20UnlockConnectorResponse =
+        await testableService.handleRequestUnlockConnector(mockStation, request)
+
+      expect(response.status).toBe(UnlockStatusEnumType.Unlocked)
+      expect(response.statusInfo).toBeUndefined()
+    })
+
+    await it('should call requestHandler (StatusNotification) to set connector status Available after unlock', async () => {
+      const { mockStation, requestHandlerMock } = createUnlockConnectorStation()
+
+      const request: OCPP20UnlockConnectorRequest = { connectorId: 1, evseId: 1 }
+      await testableService.handleRequestUnlockConnector(mockStation, request)
+
+      // sendAndSetConnectorStatus calls requestHandler internally for StatusNotification
+      expect(requestHandlerMock.mock.calls.length).toBeGreaterThan(0)
+    })
+
+    await it('handler is async — result is a Promise', async () => {
+      const { mockStation } = createUnlockConnectorStation()
+
+      const request: OCPP20UnlockConnectorRequest = { connectorId: 1, evseId: 1 }
+
+      const result = testableService.handleRequestUnlockConnector(mockStation, request)
+
+      expect(typeof (result as unknown as Promise<unknown>).then).toBe('function')
+      await result
+    })
+  })
+
+  await describe('F05 - Response structure', async () => {
+    await it('should return a plain object with a string status field', async () => {
+      const { mockStation } = createUnlockConnectorStation()
+
+      const request: OCPP20UnlockConnectorRequest = { connectorId: 1, evseId: 1 }
+      const response = await testableService.handleRequestUnlockConnector(mockStation, request)
+
+      expect(response).toBeDefined()
+      expect(typeof response).toBe('object')
+      expect(typeof response.status).toBe('string')
+    })
+
+    await it('should not include statusInfo on successful unlock', async () => {
+      const { mockStation } = createUnlockConnectorStation()
+
+      const request: OCPP20UnlockConnectorRequest = { connectorId: 1, evseId: 1 }
+      const response = await testableService.handleRequestUnlockConnector(mockStation, request)
+
+      expect(response.status).toBe(UnlockStatusEnumType.Unlocked)
+      expect(response.statusInfo).toBeUndefined()
+    })
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20Integration-Certificate.test.ts b/tests/charging-station/ocpp/2.0/OCPP20Integration-Certificate.test.ts
new file mode 100644 (file)
index 0000000..00bded3
--- /dev/null
@@ -0,0 +1,117 @@
+import { expect } from '@std/expect'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+import type {
+  OCPP20DeleteCertificateRequest,
+  OCPP20GetInstalledCertificateIdsRequest,
+  OCPP20InstallCertificateRequest,
+} from '../../../../src/types/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 { OCPPAuthServiceFactory } from '../../../../src/charging-station/ocpp/auth/services/OCPPAuthServiceFactory.js'
+import {
+  GetCertificateIdUseEnumType,
+  HashAlgorithmEnumType,
+  InstallCertificateStatusEnumType,
+  InstallCertificateUseEnumType,
+  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'
+
+/** @returns A mock station configured for certificate integration tests */
+function createIntegrationStation (): ChargingStation {
+  const { station } = createMockChargingStation({
+    baseName: TEST_CHARGING_STATION_BASE_NAME,
+    connectorsCount: 3,
+    evseConfiguration: { evsesCount: 3 },
+    heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+    ocppRequestService: {
+      requestHandler: async () => Promise.resolve({}),
+    },
+    stationInfo: {
+      ocppStrictCompliance: false,
+      ocppVersion: OCPPVersion.VERSION_201,
+    },
+    websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+  })
+  return station
+}
+
+await describe('OCPP 2.0 Integration — Certificate install and delete lifecycle', async () => {
+  let station: ChargingStation
+  let incomingRequestService: OCPP20IncomingRequestService
+  let testableService: ReturnType<typeof createTestableIncomingRequestService>
+
+  beforeEach(() => {
+    station = createIntegrationStation()
+    incomingRequestService = new OCPP20IncomingRequestService()
+    testableService = createTestableIncomingRequestService(incomingRequestService)
+  })
+
+  afterEach(() => {
+    standardCleanup()
+    OCPPAuthServiceFactory.clearAllInstances()
+  })
+
+  await it('should install a certificate and then list it via GetInstalledCertificateIds', async () => {
+    const fakePem = [
+      '-----BEGIN CERTIFICATE-----',
+      'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2a',
+      '-----END CERTIFICATE-----',
+    ].join('\n')
+
+    const installRequest: OCPP20InstallCertificateRequest = {
+      certificate: fakePem,
+      certificateType: InstallCertificateUseEnumType.V2GRootCertificate,
+    }
+
+    const installResponse = await testableService.handleRequestInstallCertificate(
+      station,
+      installRequest
+    )
+
+    expect(installResponse.status).toBeDefined()
+    expect(Object.values(InstallCertificateStatusEnumType)).toContain(installResponse.status)
+  })
+
+  await it('should respond to GetInstalledCertificateIds without throwing', async () => {
+    const getRequest: OCPP20GetInstalledCertificateIdsRequest = {
+      certificateType: [GetCertificateIdUseEnumType.V2GRootCertificate],
+    }
+
+    const getResponse = await testableService.handleRequestGetInstalledCertificateIds(
+      station,
+      getRequest
+    )
+
+    expect(getResponse).toBeDefined()
+    expect(getResponse.status).toBeDefined()
+    expect(
+      getResponse.certificateHashDataChain === undefined ||
+        Array.isArray(getResponse.certificateHashDataChain)
+    ).toBe(true)
+  })
+
+  await it('should handle DeleteCertificate request without throwing even for unknown cert hash', async () => {
+    const deleteRequest: OCPP20DeleteCertificateRequest = {
+      certificateHashData: {
+        hashAlgorithm: HashAlgorithmEnumType.SHA256,
+        issuerKeyHash: 'abc123',
+        issuerNameHash: 'def456',
+        serialNumber: '01',
+      },
+    }
+
+    const deleteResponse = await testableService.handleRequestDeleteCertificate(
+      station,
+      deleteRequest
+    )
+
+    expect(deleteResponse.status).toBeDefined()
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20Integration.test.ts b/tests/charging-station/ocpp/2.0/OCPP20Integration.test.ts
new file mode 100644 (file)
index 0000000..261e235
--- /dev/null
@@ -0,0 +1,205 @@
+import { expect } from '@std/expect'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+import type {
+  OCPP20GetVariablesRequest,
+  OCPP20GetVariablesResponse,
+  OCPP20SetVariablesRequest,
+  OCPP20SetVariablesResponse,
+} from '../../../../src/types/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 { OCPPAuthServiceFactory } from '../../../../src/charging-station/ocpp/auth/services/OCPPAuthServiceFactory.js'
+import {
+  AttributeEnumType,
+  GetVariableStatusEnumType,
+  OCPP20ComponentName,
+  OCPPVersion,
+  SetVariableStatusEnumType,
+} 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'
+import { resetLimits } from './OCPP20TestUtils.js'
+
+/** @returns A mock station configured for integration tests */
+function createIntegrationStation (): ChargingStation {
+  const { station } = createMockChargingStation({
+    baseName: TEST_CHARGING_STATION_BASE_NAME,
+    connectorsCount: 3,
+    evseConfiguration: { evsesCount: 3 },
+    heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+    ocppRequestService: {
+      requestHandler: async () => Promise.resolve({}),
+    },
+    stationInfo: {
+      ocppStrictCompliance: false,
+      ocppVersion: OCPPVersion.VERSION_201,
+    },
+    websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+  })
+  return station
+}
+
+await describe('OCPP 2.0 Integration — SetVariables → GetVariables consistency', async () => {
+  let station: ChargingStation
+  let incomingRequestService: OCPP20IncomingRequestService
+  let testableService: ReturnType<typeof createTestableIncomingRequestService>
+
+  beforeEach(() => {
+    station = createIntegrationStation()
+    incomingRequestService = new OCPP20IncomingRequestService()
+    testableService = createTestableIncomingRequestService(incomingRequestService)
+    resetLimits(station)
+  })
+
+  afterEach(() => {
+    standardCleanup()
+    OCPPAuthServiceFactory.clearAllInstances()
+  })
+
+  await it('should read back the same value that was written via SetVariables→GetVariables', () => {
+    const setRequest: OCPP20SetVariablesRequest = {
+      setVariableData: [
+        {
+          attributeValue: '60',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: 'HeartbeatInterval' },
+        },
+      ],
+    }
+    const getRequest: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          attributeType: AttributeEnumType.Actual,
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: 'HeartbeatInterval' },
+        },
+      ],
+    }
+
+    const setResponse: OCPP20SetVariablesResponse = testableService.handleRequestSetVariables(
+      station,
+      setRequest
+    )
+
+    expect(setResponse.setVariableResult).toHaveLength(1)
+    const setResult = setResponse.setVariableResult[0]
+    expect(setResult.attributeStatus).toBe(SetVariableStatusEnumType.Accepted)
+
+    const getResponse: OCPP20GetVariablesResponse = testableService.handleRequestGetVariables(
+      station,
+      getRequest
+    )
+
+    expect(getResponse.getVariableResult).toHaveLength(1)
+    const getResult = getResponse.getVariableResult[0]
+    expect(getResult.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+    expect(getResult.attributeValue).toBe('60')
+  })
+
+  await it('should return UnknownVariable for GetVariables on an unknown variable name', () => {
+    const getRequest: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: 'ThisVariableDoesNotExistInRegistry' },
+        },
+      ],
+    }
+
+    const getResponse = testableService.handleRequestGetVariables(station, getRequest)
+
+    expect(getResponse.getVariableResult).toHaveLength(1)
+    const result = getResponse.getVariableResult[0]
+    expect(
+      result.attributeStatus === GetVariableStatusEnumType.UnknownVariable ||
+        result.attributeStatus === GetVariableStatusEnumType.UnknownComponent
+    ).toBe(true)
+  })
+
+  await it('should handle multiple variables in a single SetVariables→GetVariables round trip', () => {
+    const setRequest: OCPP20SetVariablesRequest = {
+      setVariableData: [
+        {
+          attributeValue: '30',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: 'HeartbeatInterval' },
+        },
+        {
+          attributeValue: '20',
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: 'WebSocketPingInterval' },
+        },
+      ],
+    }
+
+    const setResponse = testableService.handleRequestSetVariables(station, setRequest)
+    expect(setResponse.setVariableResult).toHaveLength(2)
+
+    const getRequest: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: 'HeartbeatInterval' },
+        },
+        {
+          component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+          variable: { name: 'WebSocketPingInterval' },
+        },
+      ],
+    }
+    const getResponse = testableService.handleRequestGetVariables(station, getRequest)
+
+    expect(getResponse.getVariableResult).toHaveLength(2)
+    for (const result of getResponse.getVariableResult) {
+      expect(result.attributeStatus).toBe(GetVariableStatusEnumType.Accepted)
+    }
+  })
+
+  await it('should reject SetVariables on an unknown component and confirm GetVariables returns UnknownComponent', () => {
+    const unknownComponent = { name: 'NonExistentComponent' as OCPP20ComponentName }
+    const variableName = 'SomeVariable'
+
+    // Attempt to set a variable on a component that does not exist in the registry
+    const setRequest: OCPP20SetVariablesRequest = {
+      setVariableData: [
+        {
+          attributeValue: '999',
+          component: unknownComponent,
+          variable: { name: variableName },
+        },
+      ],
+    }
+    const setResponse = testableService.handleRequestSetVariables(station, setRequest)
+
+    expect(setResponse.setVariableResult).toHaveLength(1)
+    const setResult = setResponse.setVariableResult[0]
+    expect(
+      setResult.attributeStatus === SetVariableStatusEnumType.UnknownComponent ||
+        setResult.attributeStatus === SetVariableStatusEnumType.UnknownVariable ||
+        setResult.attributeStatus === SetVariableStatusEnumType.Rejected
+    ).toBe(true)
+
+    // Confirm GetVariables also rejects lookup on the same unknown component
+    const getRequest: OCPP20GetVariablesRequest = {
+      getVariableData: [
+        {
+          component: unknownComponent,
+          variable: { name: variableName },
+        },
+      ],
+    }
+    const getResponse = testableService.handleRequestGetVariables(station, getRequest)
+
+    expect(getResponse.getVariableResult).toHaveLength(1)
+    const getResult = getResponse.getVariableResult[0]
+    expect(
+      getResult.attributeStatus === GetVariableStatusEnumType.UnknownComponent ||
+        getResult.attributeStatus === GetVariableStatusEnumType.UnknownVariable
+    ).toBe(true)
+  })
+})
index 912d2a83c2c7c010489a7f80fed1dd7b39b4240e..5b4e52dee07b56ec610ff119b3cb1ceeb68d3288 100644 (file)
@@ -34,7 +34,7 @@ const MOCK_OCSP_RESULT = 'TW9jayBPQ1NQIFJlc3VsdCBCYXNlNjQ='
 
 await describe('OCPP20 ISO15118 Request Service', async () => {
   await describe('M02 - Get15118EVCertificate Request', async () => {
-    let station: ReturnType<typeof createMockChargingStation>
+    let station: ReturnType<typeof createMockChargingStation>['station']
 
     beforeEach(() => {
       const { station: newStation } = createMockChargingStation({
index f9eea7adba79638bdb946ffc374f49fccc2535f3..6abac45d8d1029d1f726a57bfe858f4d9ddda82a 100644 (file)
@@ -4,6 +4,7 @@
  */
 
 import { expect } from '@std/expect'
+import assert from 'node:assert'
 import { afterEach, beforeEach, describe, it } from 'node:test'
 
 import type { ChargingStation } from '../../../../src/charging-station/index.js'
@@ -325,10 +326,11 @@ await describe('B07/B08 - NotifyReport', async () => {
       ) as OCPP20NotifyReportRequest
 
       expect(payload).toBeDefined()
-      expect(payload.reportData[0].variableAttribute[0].type).toBe(attributeType)
-      expect(payload.reportData[0].variableAttribute[0].value).toBe(
-        `Test Value ${index.toString()}`
-      )
+      assert(payload.reportData != null)
+      const firstReport = payload.reportData[0]
+      assert(firstReport.variableAttribute != null)
+      expect(firstReport.variableAttribute[0].type).toBe(attributeType)
+      expect(firstReport.variableAttribute[0].value).toBe(`Test Value ${index.toString()}`)
     })
   })
 
@@ -378,8 +380,12 @@ await describe('B07/B08 - NotifyReport', async () => {
       ) as OCPP20NotifyReportRequest
 
       expect(payload).toBeDefined()
-      expect(payload.reportData[0].variableCharacteristics.dataType).toBe(testCase.dataType)
-      expect(payload.reportData[0].variableAttribute[0].value).toBe(testCase.value)
+      assert(payload.reportData != null)
+      const firstReport = payload.reportData[0]
+      assert(firstReport.variableCharacteristics != null)
+      assert(firstReport.variableAttribute != null)
+      expect(firstReport.variableCharacteristics.dataType).toBe(testCase.dataType)
+      expect(firstReport.variableAttribute[0].value).toBe(testCase.value)
     })
   })
 
@@ -485,8 +491,11 @@ await describe('B07/B08 - NotifyReport', async () => {
     ) as OCPP20NotifyReportRequest
 
     expect(payload).toBeDefined()
-    expect(payload.reportData[0].variableAttribute).toHaveLength(1)
-    expect(payload.reportData[0].variableAttribute[0].type).toBe(AttributeEnumType.Actual)
+    assert(payload.reportData != null)
+    const firstReport = payload.reportData[0]
+    assert(firstReport.variableAttribute != null)
+    expect(firstReport.variableAttribute).toHaveLength(1)
+    expect(firstReport.variableAttribute[0].type).toBe(AttributeEnumType.Actual)
   })
 
   await it('should preserve all payload properties correctly', () => {
index ddd45116c9cc3d69825065994e93f30ac3929479..6d67d71d3a911a26f3ae1266a1705108f0645a40 100644 (file)
@@ -16,6 +16,7 @@ import {
   type OCPP20SignCertificateRequest,
   type OCPP20SignCertificateResponse,
   OCPPVersion,
+  ReasonCodeEnumType,
 } from '../../../../src/types/index.js'
 import { Constants } from '../../../../src/utils/index.js'
 import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
@@ -42,7 +43,9 @@ await describe('I02 - SignCertificate Request', async () => {
     station = createdStation
     // Set up configuration with OrganizationName
     station.ocppConfiguration = {
-      configurationKey: [{ key: 'SecurityCtrlr.OrganizationName', value: MOCK_ORGANIZATION_NAME }],
+      configurationKey: [
+        { key: 'SecurityCtrlr.OrganizationName', readonly: false, value: MOCK_ORGANIZATION_NAME },
+      ],
     }
   })
 
@@ -163,7 +166,7 @@ await describe('I02 - SignCertificate Request', async () => {
         sendMessageResponse: {
           status: GenericStatus.Rejected,
           statusInfo: {
-            reasonCode: 'InvalidCSR',
+            reasonCode: ReasonCodeEnumType.InvalidCSR,
           },
         },
       })
@@ -260,12 +263,10 @@ await describe('I02 - SignCertificate Request', async () => {
 
       stationWithoutCertManager.ocppConfiguration = {
         configurationKey: [
-          { key: 'SecurityCtrlr.OrganizationName', value: MOCK_ORGANIZATION_NAME },
+          { key: 'SecurityCtrlr.OrganizationName', readonly: false, value: MOCK_ORGANIZATION_NAME },
         ],
       }
 
-      delete stationWithoutCertManager.certificateManager
-
       const { sendMessageMock, service } =
         createTestableRequestService<OCPP20SignCertificateResponse>({
           sendMessageResponse: {
diff --git a/tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts
new file mode 100644 (file)
index 0000000..fcf4d6f
--- /dev/null
@@ -0,0 +1,142 @@
+/**
+ * @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
+ */
+
+import { expect } from '@std/expect'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
+
+import type { MockChargingStation } from '../../ChargingStationTestUtils.js'
+
+import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js'
+import { OCPP20RequestCommand, OCPPVersion } from '../../../../src/types/index.js'
+import {
+  OCPP20AuthorizationStatusEnumType,
+  type OCPP20MessageContentType,
+  OCPP20MessageFormatEnumType,
+  type OCPP20TransactionEventResponse,
+} from '../../../../src/types/ocpp/2.0/Transaction.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'
+
+/**
+ * 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
+ */
+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,
+    },
+    websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+  })
+  return station as MockChargingStation
+}
+
+await describe('E01-E04 - TransactionEventResponse handler', async () => {
+  let responseService: OCPP20ResponseService
+  let mockStation: MockChargingStation
+
+  beforeEach(() => {
+    mock.timers.enable({ apis: ['setInterval', 'setTimeout'] })
+    responseService = new OCPP20ResponseService()
+    mockStation = createTransactionEventStation()
+  })
+
+  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 handle empty TransactionEvent response without throwing', async () => {
+    const payload: OCPP20TransactionEventResponse = {}
+    await expect(dispatch(payload)).resolves.toBeUndefined()
+  })
+
+  await it('should handle totalCost field without throwing', async () => {
+    const payload: OCPP20TransactionEventResponse = { totalCost: 12.5 }
+    await expect(dispatch(payload)).resolves.toBeUndefined()
+  })
+
+  await it('should handle chargingPriority field without throwing', async () => {
+    const payload: OCPP20TransactionEventResponse = { chargingPriority: 1 }
+    await expect(dispatch(payload)).resolves.toBeUndefined()
+  })
+
+  await it('should handle idTokenInfo with Accepted status without throwing', async () => {
+    const payload: OCPP20TransactionEventResponse = {
+      idTokenInfo: {
+        status: OCPP20AuthorizationStatusEnumType.Accepted,
+      },
+    }
+    await expect(dispatch(payload)).resolves.toBeUndefined()
+  })
+
+  await it('should handle idTokenInfo with Invalid status without throwing', async () => {
+    const payload: OCPP20TransactionEventResponse = {
+      idTokenInfo: {
+        status: OCPP20AuthorizationStatusEnumType.Invalid,
+      },
+    }
+    await expect(dispatch(payload)).resolves.toBeUndefined()
+  })
+
+  await it('should handle updatedPersonalMessage field without throwing', async () => {
+    const message: OCPP20MessageContentType = {
+      content: 'Thank you for charging!',
+      format: OCPP20MessageFormatEnumType.UTF8,
+    }
+    const payload: OCPP20TransactionEventResponse = { updatedPersonalMessage: message }
+    await expect(dispatch(payload)).resolves.toBeUndefined()
+  })
+
+  await it('should handle all optional fields present simultaneously without throwing', async () => {
+    const message: OCPP20MessageContentType = {
+      content: '<b>Session complete</b>',
+      format: OCPP20MessageFormatEnumType.HTML,
+    }
+    const payload: OCPP20TransactionEventResponse = {
+      chargingPriority: 2,
+      idTokenInfo: {
+        chargingPriority: 3,
+        status: OCPP20AuthorizationStatusEnumType.Accepted,
+      },
+      totalCost: 9.99,
+      updatedPersonalMessage: message,
+    }
+    await expect(dispatch(payload)).resolves.toBeUndefined()
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20SchemaValidation.test.ts b/tests/charging-station/ocpp/2.0/OCPP20SchemaValidation.test.ts
new file mode 100644 (file)
index 0000000..277cdb8
--- /dev/null
@@ -0,0 +1,197 @@
+/**
+ * @file Tests for OCPP 2.0 JSON schema validation (negative tests)
+ * @description Verifies that OCPP 2.0.1 JSON schemas correctly reject invalid payloads
+ * when compiled with AJV. Tests the schemas directly (not through service plumbing),
+ * which ensures correctness regardless of path resolution in tsx/dist modes.
+ *
+ * This approach also validates the AJV configuration (strict:false required because
+ * many OCPP 2.0 schemas use additionalItems without array items, which AJV 8 strict
+ * mode rejects at compile time).
+ */
+
+import { expect } from '@std/expect'
+import _Ajv, { type ValidateFunction } from 'ajv'
+import _ajvFormats from 'ajv-formats'
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { describe, it } from 'node:test'
+import { fileURLToPath } from 'node:url'
+
+const AjvConstructor = _Ajv.default
+const ajvFormats = _ajvFormats.default
+
+/** Absolute path to OCPP 2.0 JSON schemas, resolved relative to this test file. */
+const SCHEMA_DIR = join(
+  fileURLToPath(new URL('.', import.meta.url)),
+  '../../../../src/assets/json-schemas/ocpp/2.0'
+)
+
+/**
+ * Load a schema from the OCPP 2.0 schema directory and return parsed JSON.
+ * @param filename - Schema filename (e.g. 'ResetRequest.json')
+ * @returns Parsed JSON schema object
+ */
+function loadSchema (filename: string): Record<string, unknown> {
+  return JSON.parse(readFileSync(join(SCHEMA_DIR, filename), 'utf8')) as Record<string, unknown>
+}
+
+/**
+ * Create an AJV validator for the given schema file.
+ * strict:false is required because OCPP 2.0 schemas use additionalItems without
+ * array items (a draft-07 pattern), which AJV 8 strict mode rejects at compile time.
+ * @param schemaFile - Schema filename (e.g. 'ResetRequest.json')
+ * @returns Compiled AJV validate function
+ */
+function makeValidator (schemaFile: string): ValidateFunction {
+  const ajv = new AjvConstructor({ keywords: ['javaType'], multipleOfPrecision: 2, strict: false })
+  ajvFormats(ajv)
+  return ajv.compile(loadSchema(schemaFile))
+}
+
+await describe('OCPP 2.0 schema validation — negative tests', async () => {
+  await it('AJV compiles ResetRequest schema without error (strict:false required)', () => {
+    // Verifies the AJV configuration works for schemas using additionalItems pattern
+    expect(() => makeValidator('ResetRequest.json')).not.toThrow()
+  })
+
+  await it('AJV compiles GetVariablesRequest schema without error (uses additionalItems)', () => {
+    // GetVariablesRequest uses additionalItems:false — would fail in strict mode
+    expect(() => makeValidator('GetVariablesRequest.json')).not.toThrow()
+  })
+
+  await it('Reset: missing required "type" field → validation fails', () => {
+    const validate = makeValidator('ResetRequest.json')
+    expect(validate({})).toBe(false)
+    expect(validate.errors).toBeDefined()
+    // AJV reports missingProperty for required field violations
+    const hasMissingType = validate.errors?.some(
+      e =>
+        e.keyword === 'required' &&
+        (e.params as { missingProperty?: string }).missingProperty === 'type'
+    )
+    expect(hasMissingType).toBe(true)
+  })
+
+  await it('Reset: invalid "type" enum value → validation fails', () => {
+    const validate = makeValidator('ResetRequest.json')
+    // Valid values are Immediate and OnIdle only; HardReset is OCPP 1.6
+    expect(validate({ type: 'HardReset' })).toBe(false)
+    expect(validate.errors).toBeDefined()
+    const hasEnumError = validate.errors?.some(e => e.keyword === 'enum')
+    expect(hasEnumError).toBe(true)
+  })
+
+  await it('GetVariables: empty getVariableData array (minItems:1) → validation fails', () => {
+    const validate = makeValidator('GetVariablesRequest.json')
+    expect(validate({ getVariableData: [] })).toBe(false)
+    expect(validate.errors).toBeDefined()
+    const hasMinItemsError = validate.errors?.some(e => e.keyword === 'minItems')
+    expect(hasMinItemsError).toBe(true)
+  })
+
+  await it('GetVariables: missing required getVariableData → validation fails', () => {
+    const validate = makeValidator('GetVariablesRequest.json')
+    expect(validate({})).toBe(false)
+    expect(validate.errors).toBeDefined()
+    const hasMissingProp = validate.errors?.some(
+      e =>
+        e.keyword === 'required' &&
+        (e.params as { missingProperty?: string }).missingProperty === 'getVariableData'
+    )
+    expect(hasMissingProp).toBe(true)
+  })
+
+  await it('SetVariables: missing required setVariableData → validation fails', () => {
+    const validate = makeValidator('SetVariablesRequest.json')
+    expect(validate({})).toBe(false)
+    expect(validate.errors).toBeDefined()
+    const hasMissingProp = validate.errors?.some(
+      e =>
+        e.keyword === 'required' &&
+        (e.params as { missingProperty?: string }).missingProperty === 'setVariableData'
+    )
+    expect(hasMissingProp).toBe(true)
+  })
+
+  await it('TriggerMessage: invalid requestedMessage enum value → validation fails', () => {
+    const validate = makeValidator('TriggerMessageRequest.json')
+    expect(validate({ requestedMessage: 'INVALID_MESSAGE_TYPE_XYZ' })).toBe(false)
+    expect(validate.errors).toBeDefined()
+    const hasEnumError = validate.errors?.some(e => e.keyword === 'enum')
+    expect(hasEnumError).toBe(true)
+  })
+
+  await it('TriggerMessage: missing required requestedMessage → validation fails', () => {
+    const validate = makeValidator('TriggerMessageRequest.json')
+    expect(validate({})).toBe(false)
+    expect(validate.errors).toBeDefined()
+    const hasMissingProp = validate.errors?.some(
+      e =>
+        e.keyword === 'required' &&
+        (e.params as { missingProperty?: string }).missingProperty === 'requestedMessage'
+    )
+    expect(hasMissingProp).toBe(true)
+  })
+
+  await it('UnlockConnector: missing required "evseId" → validation fails', () => {
+    const validate = makeValidator('UnlockConnectorRequest.json')
+    expect(validate({ connectorId: 1 })).toBe(false)
+    expect(validate.errors).toBeDefined()
+    const hasMissingProp = validate.errors?.some(
+      e =>
+        e.keyword === 'required' &&
+        (e.params as { missingProperty?: string }).missingProperty === 'evseId'
+    )
+    expect(hasMissingProp).toBe(true)
+  })
+
+  await it('UnlockConnector: missing required "connectorId" → validation fails', () => {
+    const validate = makeValidator('UnlockConnectorRequest.json')
+    expect(validate({ evseId: 1 })).toBe(false)
+    expect(validate.errors).toBeDefined()
+    const hasMissingProp = validate.errors?.some(
+      e =>
+        e.keyword === 'required' &&
+        (e.params as { missingProperty?: string }).missingProperty === 'connectorId'
+    )
+    expect(hasMissingProp).toBe(true)
+  })
+
+  await it('RequestStartTransaction: missing required "idToken" → validation fails', () => {
+    const validate = makeValidator('RequestStartTransactionRequest.json')
+    // remoteStartId is also required; provide it but omit idToken
+    expect(validate({ remoteStartId: 1 })).toBe(false)
+    expect(validate.errors).toBeDefined()
+    const hasMissingProp = validate.errors?.some(
+      e =>
+        e.keyword === 'required' &&
+        (e.params as { missingProperty?: string }).missingProperty === 'idToken'
+    )
+    expect(hasMissingProp).toBe(true)
+  })
+
+  await it('CertificateSigned: missing required certificateChain → validation fails', () => {
+    const validate = makeValidator('CertificateSignedRequest.json')
+    expect(validate({})).toBe(false)
+    expect(validate.errors).toBeDefined()
+    const hasMissingProp = validate.errors?.some(
+      e =>
+        e.keyword === 'required' &&
+        (e.params as { missingProperty?: string }).missingProperty === 'certificateChain'
+    )
+    expect(hasMissingProp).toBe(true)
+  })
+
+  await it('Reset: valid payload passes validation', () => {
+    const validate = makeValidator('ResetRequest.json')
+    expect(validate({ type: 'Immediate' })).toBe(true)
+    expect(validate({ type: 'OnIdle' })).toBe(true)
+    expect(validate({ evseId: 1, type: 'OnIdle' })).toBe(true)
+  })
+
+  await it('TriggerMessage: valid payload passes validation', () => {
+    const validate = makeValidator('TriggerMessageRequest.json')
+    expect(validate({ requestedMessage: 'Heartbeat' })).toBe(true)
+    expect(validate({ requestedMessage: 'BootNotification' })).toBe(true)
+  })
+})
index 2c22455175aa0e3c2d1d38977613022833f8c473..6e9968f693edcd0de5b14b34a3692e511125a00e 100644 (file)
@@ -11,6 +11,7 @@
  */
 
 import { expect } from '@std/expect'
+import assert from 'node:assert'
 import { afterEach, beforeEach, describe, it, mock } from 'node:test'
 
 import type { ChargingStation } from '../../../../src/charging-station/ChargingStation.js'
@@ -23,6 +24,7 @@ import {
   OCPP20TriggerReasonEnumType,
   OCPPVersion,
 } from '../../../../src/types/index.js'
+import { OCPP20IncomingRequestCommand } from '../../../../src/types/ocpp/2.0/Requests.js'
 import {
   OCPP20ChargingStateEnumType,
   OCPP20IdTokenEnumType,
@@ -468,7 +470,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
       await describe('selectTriggerReason', async () => {
         await it('should select RemoteStart for remote_command context with RequestStartTransaction', () => {
           const context: OCPP20TransactionContext = {
-            command: 'RequestStartTransaction',
+            command: OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
             source: 'remote_command',
           }
 
@@ -482,7 +484,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
 
         await it('should select RemoteStop for remote_command context with RequestStopTransaction', () => {
           const context: OCPP20TransactionContext = {
-            command: 'RequestStopTransaction',
+            command: OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
             source: 'remote_command',
           }
 
@@ -496,7 +498,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
 
         await it('should select UnlockCommand for remote_command context with UnlockConnector', () => {
           const context: OCPP20TransactionContext = {
-            command: 'UnlockConnector',
+            command: OCPP20IncomingRequestCommand.UNLOCK_CONNECTOR,
             source: 'remote_command',
           }
 
@@ -510,7 +512,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
 
         await it('should select ResetCommand for remote_command context with Reset', () => {
           const context: OCPP20TransactionContext = {
-            command: 'Reset',
+            command: OCPP20IncomingRequestCommand.RESET,
             source: 'remote_command',
           }
 
@@ -524,7 +526,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
 
         await it('should select Trigger for remote_command context with TriggerMessage', () => {
           const context: OCPP20TransactionContext = {
-            command: 'TriggerMessage',
+            command: OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
             source: 'remote_command',
           }
 
@@ -759,7 +761,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
           // Test context with multiple applicable triggers - priority should be respected
           const context: OCPP20TransactionContext = {
             cableState: 'plugged_in', // Even lower priority
-            command: 'RequestStartTransaction',
+            command: OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
             isDeauthorized: true, // Lower priority but should be overridden
             source: 'remote_command', // High priority
           }
@@ -806,7 +808,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
           const connectorId = 1
           const transactionId = generateUUID()
           const context: OCPP20TransactionContext = {
-            command: 'RequestStartTransaction',
+            command: OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
             source: 'remote_command',
           }
 
@@ -1964,7 +1966,8 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
         expect(response.idTokenInfo).toBeUndefined()
 
         const connector = mockStation.getConnectorStatus(connectorId)
-        expect(connector?.transactionEventQueue).toBeDefined()
+        assert(connector != null)
+        assert(connector.transactionEventQueue != null)
         expect(connector.transactionEventQueue.length).toBe(1)
         expect(connector.transactionEventQueue[0].seqNo).toBe(0)
       })
@@ -2003,6 +2006,8 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
 
         const connector = mockStation.getConnectorStatus(connectorId)
         expect(connector?.transactionEventQueue?.length).toBe(3)
+        assert(connector != null)
+        assert(connector.transactionEventQueue != null)
 
         expect(connector.transactionEventQueue[0].seqNo).toBe(0)
         expect(connector.transactionEventQueue[1].seqNo).toBe(1)
@@ -2057,6 +2062,8 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
 
         const connector = mockStation.getConnectorStatus(connectorId)
         expect(connector?.transactionEventQueue?.length).toBe(2)
+        assert(connector != null)
+        assert(connector.transactionEventQueue != null)
         expect(connector.transactionEventQueue[0].seqNo).toBe(1)
         expect(connector.transactionEventQueue[1].seqNo).toBe(2)
       })
@@ -2080,6 +2087,8 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
 
         const connector = mockStation.getConnectorStatus(connectorId)
         expect(connector?.transactionEventQueue?.[0]?.timestamp).toBeInstanceOf(Date)
+        assert(connector != null)
+        assert(connector.transactionEventQueue != null)
         expect(connector.transactionEventQueue[0].timestamp.getTime()).toBeGreaterThanOrEqual(
           beforeQueue.getTime()
         )
@@ -2141,6 +2150,8 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
 
         const connector = mockStation.getConnectorStatus(connectorId)
         expect(connector?.transactionEventQueue?.length).toBe(1)
+        assert(connector != null)
+        assert(connector.transactionEventQueue != null)
 
         setOnline(true)
         await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockStation, connectorId)
@@ -2204,6 +2215,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
       await it('should handle null queue gracefully', async () => {
         const connectorId = 1
         const connector = mockStation.getConnectorStatus(connectorId)
+        assert(connector != null)
         connector.transactionEventQueue = undefined
 
         await expect(
@@ -2310,6 +2322,10 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
 
         expect(connector1?.transactionEventQueue?.length).toBe(2)
         expect(connector2?.transactionEventQueue?.length).toBe(1)
+        assert(connector1 != null)
+        assert(connector1.transactionEventQueue != null)
+        assert(connector2 != null)
+        assert(connector2.transactionEventQueue != null)
 
         expect(connector1.transactionEventQueue[0].request.transactionInfo.transactionId).toBe(
           transactionId1
@@ -2348,7 +2364,9 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
         await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockStation, 1)
 
         expect(sentRequests.length).toBe(1)
-        expect(sentRequests[0].payload.transactionInfo.transactionId).toBe(transactionId1)
+        expect(
+          (sentRequests[0].payload.transactionInfo as Record<string, unknown>).transactionId
+        ).toBe(transactionId1)
 
         const connector2 = mockStation.getConnectorStatus(2)
         expect(connector2?.transactionEventQueue?.length).toBe(1)
@@ -2356,7 +2374,9 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
         await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockStation, 2)
 
         expect(sentRequests.length).toBe(2)
-        expect(sentRequests[1].payload.transactionInfo.transactionId).toBe(transactionId2)
+        expect(
+          (sentRequests[1].payload.transactionInfo as Record<string, unknown>).transactionId
+        ).toBe(transactionId2)
       })
     })
 
@@ -2477,6 +2497,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
         // Simulate startTxUpdatedInterval with zero interval
         const connector = mockStation.getConnectorStatus(connectorId)
         expect(connector).toBeDefined()
+        assert(connector != null)
 
         // Zero interval should not start timer
         // This is verified by the implementation logging debug message
@@ -2487,6 +2508,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
         const connectorId = 1
         const connector = mockStation.getConnectorStatus(connectorId)
         expect(connector).toBeDefined()
+        assert(connector != null)
 
         // Negative interval should not start timer
         expect(connector.transactionTxUpdatedSetInterval).toBeUndefined()
@@ -2600,7 +2622,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
 
         // Verify EVSE info is present
         expect(sentRequests[0].payload.evse).toBeDefined()
-        expect(sentRequests[0].payload.evse.id).toBe(connectorId)
+        expect((sentRequests[0].payload.evse as Record<string, unknown>).id).toBe(connectorId)
       })
 
       await it('should include transactionInfo with correct transactionId', async () => {
@@ -2619,7 +2641,9 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
 
         // Verify transactionInfo contains the transaction ID
         expect(sentRequests[0].payload.transactionInfo).toBeDefined()
-        expect(sentRequests[0].payload.transactionInfo.transactionId).toBe(transactionId)
+        expect(
+          (sentRequests[0].payload.transactionInfo as Record<string, unknown>).transactionId
+        ).toBe(transactionId)
       })
     })
 
diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-enforceMessageLimits.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-enforceMessageLimits.test.ts
new file mode 100644 (file)
index 0000000..40851b6
--- /dev/null
@@ -0,0 +1,375 @@
+import { expect } from '@std/expect'
+import { describe, it } from 'node:test'
+
+import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
+
+interface MockLogger {
+  debug: (...args: unknown[]) => void
+  debugCalls: unknown[][]
+}
+
+interface RejectedResult {
+  info: string
+  original: TestItem
+  reasonCode: string
+}
+
+interface TestItem {
+  attributeValue?: string
+  component: { name: string }
+  variable: { name: string }
+}
+
+/**
+ * @param name - Variable name for the test item
+ * @param value - Optional attribute value
+ * @returns A test item with the given variable name and optional value
+ */
+function makeItem (name: string, value?: string): TestItem {
+  return {
+    component: { name: 'TestComponent' },
+    variable: { name },
+    ...(value !== undefined ? { attributeValue: value } : {}),
+  }
+}
+
+/** @returns A mock logger that captures debug calls */
+function makeMockLogger (): MockLogger {
+  const debugCalls: unknown[][] = []
+  return {
+    debug (...args: unknown[]) {
+      debugCalls.push(args)
+    },
+    debugCalls,
+  }
+}
+
+/** @returns A mock station with a logPrefix method */
+function makeMockStation () {
+  return { logPrefix: () => '[TestStation]' }
+}
+
+/** @returns A builder function that creates rejected result objects */
+function makeRejectedBuilder () {
+  return (item: TestItem, reason: { info: string; reasonCode: string }): RejectedResult => ({
+    info: reason.info,
+    original: item,
+    reasonCode: reason.reasonCode,
+  })
+}
+
+await describe('OCPP20ServiceUtils.enforceMessageLimits', async () => {
+  await describe('no limits configured (both 0)', async () => {
+    await it('should return rejected:false and empty results when both limits are 0', () => {
+      const station = makeMockStation()
+      const logger = makeMockLogger()
+      const items = [makeItem('HeartbeatInterval', '30')]
+
+      const result = OCPP20ServiceUtils.enforceMessageLimits(
+        station,
+        'OCPP20ServiceUtils',
+        'enforceMessageLimits',
+        items,
+        0,
+        0,
+        makeRejectedBuilder(),
+        logger
+      )
+
+      expect(result.rejected).toBe(false)
+      expect(result.results).toStrictEqual([])
+    })
+
+    await it('should return rejected:false for empty data array with both limits 0', () => {
+      const station = makeMockStation()
+      const logger = makeMockLogger()
+
+      const result = OCPP20ServiceUtils.enforceMessageLimits(
+        station,
+        'OCPP20ServiceUtils',
+        'enforceMessageLimits',
+        [],
+        0,
+        0,
+        makeRejectedBuilder(),
+        logger
+      )
+
+      expect(result.rejected).toBe(false)
+      expect(result.results).toStrictEqual([])
+    })
+  })
+
+  await describe('itemsLimit enforcement', async () => {
+    await it('should return rejected:false when data length is under the items limit', () => {
+      const station = makeMockStation()
+      const logger = makeMockLogger()
+      const items = [makeItem('A'), makeItem('B'), makeItem('C')]
+
+      const result = OCPP20ServiceUtils.enforceMessageLimits(
+        station,
+        'OCPP20ServiceUtils',
+        'enforceMessageLimits',
+        items,
+        5,
+        0,
+        makeRejectedBuilder(),
+        logger
+      )
+
+      expect(result.rejected).toBe(false)
+      expect(result.results).toStrictEqual([])
+    })
+
+    await it('should return rejected:false when data length equals the items limit', () => {
+      const station = makeMockStation()
+      const logger = makeMockLogger()
+      const items = [makeItem('A')]
+
+      const result = OCPP20ServiceUtils.enforceMessageLimits(
+        station,
+        'OCPP20ServiceUtils',
+        'enforceMessageLimits',
+        items,
+        1,
+        0,
+        makeRejectedBuilder(),
+        logger
+      )
+
+      expect(result.rejected).toBe(false)
+      expect(result.results).toStrictEqual([])
+    })
+
+    await it('should reject all items with TooManyElements when items limit is exceeded', () => {
+      const station = makeMockStation()
+      const logger = makeMockLogger()
+      const items = [makeItem('A'), makeItem('B'), makeItem('C')]
+
+      const result = OCPP20ServiceUtils.enforceMessageLimits(
+        station,
+        'OCPP20ServiceUtils',
+        'enforceMessageLimits',
+        items,
+        2,
+        0,
+        makeRejectedBuilder(),
+        logger
+      )
+
+      expect(result.rejected).toBe(true)
+      expect(result.results).toHaveLength(3)
+      for (const r of result.results as RejectedResult[]) {
+        expect(r.reasonCode).toBe('TooManyElements')
+        expect(r.info).toContain('ItemsPerMessage limit 2')
+      }
+    })
+
+    await it('should reject exactly one-over-limit case with TooManyElements', () => {
+      const station = makeMockStation()
+      const logger = makeMockLogger()
+      const items = [makeItem('A'), makeItem('B')]
+
+      const result = OCPP20ServiceUtils.enforceMessageLimits(
+        station,
+        'OCPP20ServiceUtils',
+        'enforceMessageLimits',
+        items,
+        1,
+        0,
+        makeRejectedBuilder(),
+        logger
+      )
+
+      expect(result.rejected).toBe(true)
+      expect(result.results).toHaveLength(2)
+      for (const r of result.results as RejectedResult[]) {
+        expect(r.reasonCode).toBe('TooManyElements')
+      }
+    })
+
+    await it('should log a debug message when items limit is exceeded', () => {
+      const station = makeMockStation()
+      const logger = makeMockLogger()
+      const items = [makeItem('A'), makeItem('B'), makeItem('C')]
+
+      OCPP20ServiceUtils.enforceMessageLimits(
+        station,
+        'TestModule',
+        'testContext',
+        items,
+        2,
+        0,
+        makeRejectedBuilder(),
+        logger
+      )
+
+      expect(logger.debugCalls).toHaveLength(1)
+      expect(String(logger.debugCalls[0][0])).toContain('ItemsPerMessage limit')
+    })
+  })
+
+  await describe('bytesLimit enforcement', async () => {
+    await it('should return rejected:false when data size is under the bytes limit', () => {
+      const station = makeMockStation()
+      const logger = makeMockLogger()
+      const items = [makeItem('HeartbeatInterval', '30')]
+
+      const result = OCPP20ServiceUtils.enforceMessageLimits(
+        station,
+        'OCPP20ServiceUtils',
+        'enforceMessageLimits',
+        items,
+        0,
+        999_999,
+        makeRejectedBuilder(),
+        logger
+      )
+
+      expect(result.rejected).toBe(false)
+      expect(result.results).toStrictEqual([])
+    })
+
+    await it('should reject all items with TooLargeElement when bytes limit is exceeded', () => {
+      const station = makeMockStation()
+      const logger = makeMockLogger()
+      const items = [makeItem('SomeVariable', 'someValue')]
+
+      const result = OCPP20ServiceUtils.enforceMessageLimits(
+        station,
+        'OCPP20ServiceUtils',
+        'enforceMessageLimits',
+        items,
+        0,
+        1,
+        makeRejectedBuilder(),
+        logger
+      )
+
+      expect(result.rejected).toBe(true)
+      expect(result.results).toHaveLength(1)
+      const r = (result.results as RejectedResult[])[0]
+      expect(r.reasonCode).toBe('TooLargeElement')
+      expect(r.info).toContain('BytesPerMessage limit 1')
+    })
+
+    await it('should reject all items with TooLargeElement for multiple items over bytes limit', () => {
+      const station = makeMockStation()
+      const logger = makeMockLogger()
+      const items = [makeItem('A', 'val'), makeItem('B', 'val')]
+
+      const result = OCPP20ServiceUtils.enforceMessageLimits(
+        station,
+        'OCPP20ServiceUtils',
+        'enforceMessageLimits',
+        items,
+        0,
+        1,
+        makeRejectedBuilder(),
+        logger
+      )
+
+      expect(result.rejected).toBe(true)
+      expect(result.results).toHaveLength(2)
+      for (const r of result.results as RejectedResult[]) {
+        expect(r.reasonCode).toBe('TooLargeElement')
+      }
+    })
+
+    await it('should log a debug message when bytes limit is exceeded', () => {
+      const station = makeMockStation()
+      const logger = makeMockLogger()
+      const items = [makeItem('SomeVariable', 'someValue')]
+
+      OCPP20ServiceUtils.enforceMessageLimits(
+        station,
+        'TestModule',
+        'testContext',
+        items,
+        0,
+        1,
+        makeRejectedBuilder(),
+        logger
+      )
+
+      expect(logger.debugCalls).toHaveLength(1)
+      expect(String(logger.debugCalls[0][0])).toContain('BytesPerMessage limit')
+    })
+  })
+
+  await describe('items limit takes precedence over bytes limit', async () => {
+    await it('should apply items limit check before bytes limit check', () => {
+      const station = makeMockStation()
+      const logger = makeMockLogger()
+      const items = [makeItem('A'), makeItem('B'), makeItem('C')]
+
+      const result = OCPP20ServiceUtils.enforceMessageLimits(
+        station,
+        'OCPP20ServiceUtils',
+        'enforceMessageLimits',
+        items,
+        2,
+        1,
+        makeRejectedBuilder(),
+        logger
+      )
+
+      expect(result.rejected).toBe(true)
+      for (const r of result.results as RejectedResult[]) {
+        expect(r.reasonCode).toBe('TooManyElements')
+      }
+    })
+  })
+
+  await describe('buildRejected callback', async () => {
+    await it('should pass original item to buildRejected callback', () => {
+      const station = makeMockStation()
+      const logger = makeMockLogger()
+      const item = makeItem('HeartbeatInterval', 'abc')
+      const capturedItems: TestItem[] = []
+
+      OCPP20ServiceUtils.enforceMessageLimits(
+        station,
+        'OCPP20ServiceUtils',
+        'enforceMessageLimits',
+        [item],
+        0,
+        1,
+        (i: TestItem, _reason) => {
+          capturedItems.push(i)
+          return { rejected: true }
+        },
+        logger
+      )
+
+      expect(capturedItems).toHaveLength(1)
+      expect(capturedItems[0]).toBe(item)
+    })
+
+    await it('should pass reason with info and reasonCode to buildRejected callback', () => {
+      const station = makeMockStation()
+      const logger = makeMockLogger()
+      const item = makeItem('WebSocketPingInterval', 'xyz')
+      const capturedReasons: { info: string; reasonCode: string }[] = []
+
+      OCPP20ServiceUtils.enforceMessageLimits(
+        station,
+        'OCPP20ServiceUtils',
+        'enforceMessageLimits',
+        [item],
+        0,
+        1,
+        (_i: TestItem, reason) => {
+          capturedReasons.push(reason)
+          return { rejected: true }
+        },
+        logger
+      )
+
+      expect(capturedReasons).toHaveLength(1)
+      expect(capturedReasons[0].reasonCode).toBe('TooLargeElement')
+      expect(typeof capturedReasons[0].info).toBe('string')
+      expect(capturedReasons[0].info.length).toBeGreaterThan(0)
+    })
+  })
+})
index 09e1c02c26ddd6c4ea04cdbdbd8bfcb39ad2aea3..a92bc75bd762bb32621e6cd7710a701135d18687 100644 (file)
@@ -21,10 +21,12 @@ import { OCPP20RequestService } from '../../../../src/charging-station/ocpp/2.0/
 import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js'
 import {
   ConnectorStatusEnum,
+  DeleteCertificateStatusEnumType,
   HashAlgorithmEnumType,
   OCPP20RequiredVariableName,
   OCPPVersion,
 } from '../../../../src/types/index.js'
+import { OCPP20IncomingRequestCommand } from '../../../../src/types/ocpp/2.0/Requests.js'
 import { OCPP20IdTokenEnumType } from '../../../../src/types/ocpp/2.0/Transaction.js'
 import { Constants } from '../../../../src/utils/index.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
@@ -118,12 +120,13 @@ export function createMockStationWithRequestTracking (): MockStationWithTracking
   const sentRequests: CapturedOCPPRequest[] = []
   let isOnline = true
 
-  const requestHandlerMock = mock.fn(
-    async (_station: ChargingStation, command: string, payload: Record<string, unknown>) => {
-      sentRequests.push({ command, payload })
-      return Promise.resolve({} as EmptyObject)
-    }
-  )
+  const requestHandlerMock = mock.fn(async (...args: unknown[]) => {
+    sentRequests.push({
+      command: args[1] as string,
+      payload: args[2] as Record<string, unknown>,
+    })
+    return Promise.resolve({} as EmptyObject)
+  })
 
   const { station } = createMockChargingStation({
     baseName: TEST_CHARGING_STATION_BASE_NAME,
@@ -560,7 +563,7 @@ export const TransactionContextFixtures = {
    * @returns An OCPP20TransactionContext for remote start.
    */
   remoteStart: (): OCPP20TransactionContext => ({
-    command: 'RequestStartTransaction',
+    command: OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
     source: 'remote_command',
   }),
 
@@ -569,7 +572,7 @@ export const TransactionContextFixtures = {
    * @returns An OCPP20TransactionContext for remote stop.
    */
   remoteStop: (): OCPP20TransactionContext => ({
-    command: 'RequestStopTransaction',
+    command: OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
     source: 'remote_command',
   }),
 
@@ -578,7 +581,7 @@ export const TransactionContextFixtures = {
    * @returns An OCPP20TransactionContext for reset.
    */
   reset: (): OCPP20TransactionContext => ({
-    command: 'Reset',
+    command: OCPP20IncomingRequestCommand.RESET,
     source: 'remote_command',
   }),
 
@@ -617,7 +620,7 @@ export const TransactionContextFixtures = {
    * @returns An OCPP20TransactionContext for trigger message.
    */
   triggerMessage: (): OCPP20TransactionContext => ({
-    command: 'TriggerMessage',
+    command: OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
     source: 'remote_command',
   }),
 
@@ -628,7 +631,7 @@ export const TransactionContextFixtures = {
    * @returns An OCPP20TransactionContext for unlock connector.
    */
   unlockConnector: (): OCPP20TransactionContext => ({
-    command: 'UnlockConnector',
+    command: OCPP20IncomingRequestCommand.UNLOCK_CONNECTOR,
     source: 'remote_command',
   }),
 } as const
@@ -736,12 +739,12 @@ export const TransactionFlowPatterns: TransactionFlowPattern[] = [
 export interface MockCertificateManagerOptions {
   /** Error to throw when deleteCertificate is called */
   deleteCertificateError?: Error
-  /** Result to return from deleteCertificate (default: { status: 'Accepted' }) */
-  deleteCertificateResult?: { status: 'Accepted' | 'Failed' | 'NotFound' }
+  /** Result to return from deleteCertificate (default: { status: DeleteCertificateStatusEnumType.Accepted }) */
+  deleteCertificateResult?: { status: DeleteCertificateStatusEnumType }
   /** Error to throw when getInstalledCertificates is called */
   getInstalledCertificatesError?: Error
   /** Result to return from getInstalledCertificates (default: []) */
-  getInstalledCertificatesResult?: unknown[]
+  getInstalledCertificatesResult?: CertificateHashDataChainType[]
   /** Error to throw when storeCertificate is called */
   storeCertificateError?: Error
   /** Result to return from storeCertificate (default: { success: true }) */
@@ -820,7 +823,7 @@ export function createMockCertificateManager (options: MockCertificateManagerOpt
       if (options.deleteCertificateError != null) {
         throw options.deleteCertificateError
       }
-      return options.deleteCertificateResult ?? { status: 'Accepted' }
+      return options.deleteCertificateResult ?? { status: DeleteCertificateStatusEnumType.Accepted }
     }),
     getInstalledCertificates: mock.fn(() => {
       if (options.getInstalledCertificatesError != null) {
index 87db619fe87913fe95500e295c216b686fade706..dc7eccff6098cdb6c370bc35445284ad22d63627 100644 (file)
@@ -340,7 +340,7 @@ await describe('B05 - OCPP20VariableManager', async () => {
       expect(result[2].attributeStatusInfo).toBeUndefined()
     })
 
-    await it('should reject EVSE component as unsupported', () => {
+    await it('should reject unknown variable on EVSE component', () => {
       const request: OCPP20GetVariableDataType[] = [
         {
           component: {
@@ -355,7 +355,7 @@ await describe('B05 - OCPP20VariableManager', async () => {
 
       expect(Array.isArray(result)).toBe(true)
       expect(result).toHaveLength(1)
-      expect(result[0].attributeStatus).toBe(GetVariableStatusEnumType.UnknownComponent)
+      expect(result[0].attributeStatus).toBe(GetVariableStatusEnumType.UnknownVariable)
       expect(result[0].attributeType).toBe(AttributeEnumType.Actual)
       expect(result[0].attributeValue).toBeUndefined()
       expect(result[0].component.name).toBe(OCPP20ComponentName.EVSE)
index 94ac175a0eba74fe4d921c56d19339e674e6c5db..bf5124f08cc161e0597ad48f5eabf017e74d2cfe 100644 (file)
@@ -6,7 +6,7 @@ import { expect } from '@std/expect'
 import { afterEach, beforeEach, describe, it } from 'node:test'
 
 import type { ChargingStation } from '../../../../../src/charging-station/ChargingStation.js'
-import type { OCPP16AuthorizeResponse } from '../../../../../src/types/ocpp/1.6/Responses.js'
+import type { OCPP16AuthorizeResponse } from '../../../../../src/types/ocpp/1.6/Transaction.js'
 
 import { OCPP16AuthAdapter } from '../../../../../src/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.js'
 import {
index 092051382d8f395d419efa0a6482dc1e54da445c..115582881dcfe50b66056b790a7a51542739fe13 100644 (file)
@@ -110,7 +110,9 @@ await describe('UIWebSocketServer', async () => {
       ProcedureName.LIST_CHARGING_STATIONS,
       {},
     ])
-    server.sendResponse(response)
+    if (response != null) {
+      server.sendResponse(response)
+    }
 
     expect(server.hasResponseHandler(TEST_UUID)).toBe(false)
   })