From 26c08ac2eaedc641d67cdc5d47c9ef5bc19e69d0 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Tue, 3 Mar 2026 18:08:43 +0100 Subject: [PATCH] =?utf8?q?refactor(ocpp2):=20OCPP=202.0.1=20audit=20fixes?= =?utf8?q?=20=E2=80=94=20spec=20compliance,=20type=20safety,=20test=20cove?= =?utf8?q?rage=20(#1697)?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * 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> --- .github/workflows/ci.yml | 3 + README.md | 36 +- package.json | 1 + scripts/runtime.d.ts | 8 + src/charging-station/ChargingStation.ts | 22 +- src/charging-station/Helpers.ts | 228 ++++--- .../ChargingStationWorkerBroadcastChannel.ts | 3 +- .../ocpp/1.6/OCPP16RequestService.ts | 6 +- .../ocpp/1.6/OCPP16ResponseService.ts | 3 +- .../ocpp/1.6/OCPP16ServiceUtils.ts | 21 +- .../ocpp/2.0/OCPP20CertificateManager.ts | 36 +- .../ocpp/2.0/OCPP20Constants.ts | 6 + .../ocpp/2.0/OCPP20IncomingRequestService.ts | 620 ++++++++++++------ .../ocpp/2.0/OCPP20RequestService.ts | 2 - .../ocpp/2.0/OCPP20ResponseService.ts | 36 + .../ocpp/2.0/OCPP20ServiceUtils.ts | 124 +++- .../ocpp/2.0/OCPP20VariableManager.ts | 96 +-- .../OCPP20RequestServiceTestable.ts | 2 +- .../ocpp/2.0/__testable__/index.ts | 18 +- src/charging-station/ocpp/OCPPServiceUtils.ts | 322 +++++---- src/performance/storage/MikroOrmStorage.ts | 4 +- src/types/index.ts | 8 + src/types/ocpp/1.6/Requests.ts | 8 +- src/types/ocpp/1.6/Responses.ts | 10 +- src/types/ocpp/2.0/Common.ts | 27 + src/types/ocpp/2.0/Requests.ts | 18 +- src/types/ocpp/2.0/Responses.ts | 16 + src/types/ocpp/2.0/Transaction.ts | 1 + src/types/ocpp/2.0/index.ts | 8 - src/types/ocpp/ChargingProfile.ts | 4 + src/types/ocpp/Common.ts | 2 +- src/types/ocpp/Requests.ts | 3 +- src/types/ocpp/Responses.ts | 4 +- .../ChargingStation-Connectors.test.ts | 12 +- .../ChargingStation-Lifecycle.test.ts | 4 +- .../ChargingStation-Resilience.test.ts | 96 ++- .../ChargingStation-Transactions.test.ts | 11 +- .../charging-station/ChargingStation.test.ts | 3 +- tests/charging-station/Helpers.test.ts | 92 +-- .../helpers/StationHelpers.ts | 60 +- .../ocpp/2.0/OCPP20CertificateManager.test.ts | 2 +- ...ngRequestService-CertificateSigned.test.ts | 29 +- ...ngRequestService-DeleteCertificate.test.ts | 10 +- ...ncomingRequestService-GetVariables.test.ts | 2 +- ...gRequestService-InstallCertificate.test.ts | 4 +- ...mingRequestService-RemoteStartAuth.test.ts | 6 + ...estService-RequestStartTransaction.test.ts | 11 +- ...OCPP20IncomingRequestService-Reset.test.ts | 83 ++- ...omingRequestService-TriggerMessage.test.ts | 359 ++++++++++ ...mingRequestService-UnlockConnector.test.ts | 228 +++++++ .../2.0/OCPP20Integration-Certificate.test.ts | 117 ++++ .../ocpp/2.0/OCPP20Integration.test.ts | 205 ++++++ .../2.0/OCPP20RequestService-ISO15118.test.ts | 2 +- .../OCPP20RequestService-NotifyReport.test.ts | 25 +- ...PP20RequestService-SignCertificate.test.ts | 11 +- ...20ResponseService-TransactionEvent.test.ts | 142 ++++ .../ocpp/2.0/OCPP20SchemaValidation.test.ts | 197 ++++++ ...CPP20ServiceUtils-TransactionEvent.test.ts | 48 +- ...0ServiceUtils-enforceMessageLimits.test.ts | 375 +++++++++++ .../ocpp/2.0/OCPP20TestUtils.ts | 33 +- .../ocpp/2.0/OCPP20VariableManager.test.ts | 4 +- .../auth/adapters/OCPP16AuthAdapter.test.ts | 2 +- .../ui-server/UIWebSocketServer.test.ts | 4 +- 63 files changed, 3088 insertions(+), 795 deletions(-) create mode 100644 scripts/runtime.d.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UnlockConnector.test.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20Integration-Certificate.test.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20Integration.test.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20SchemaValidation.test.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-enforceMessageLimits.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6c4290f..b1de4a1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 5810dfec..578121e0 100644 --- 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 diff --git a/package.json b/package.json index dec550dc..aa3d4b88 100644 --- a/package.json +++ b/package.json @@ -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 index 00000000..518d3b19 --- /dev/null +++ b/scripts/runtime.d.ts @@ -0,0 +1,8 @@ +export declare const JSRuntime: { + browser: 'browser' + bun: 'bun' + deno: 'deno' + node: 'node' + workerd: 'workerd' +} +export declare const runtime: string diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index 008033c9..db4469a3 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -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( 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 { - 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( this, @@ -1193,14 +1189,14 @@ export class ChargingStation extends EventEmitter { connectorId, meterValue: [transactionEndMeterValue], transactionId, - } + } as MeterValuesRequest ) } return await this.ocppRequestService.requestHandler< Partial, StopTransactionResponse >(this, RequestCommand.STOP_TRANSACTION, { - meterStop: this.getEnergyActiveImportRegisterByTransactionId(transactionId, true), + meterStop: this.getEnergyActiveImportRegisterByTransactionId(rawTransactionId, true), transactionId, ...(reason != null && { reason }), }) diff --git a/src/charging-station/Helpers.ts b/src/charging-station/Helpers.ts index 35a596b4..231ddda5 100644 --- a/src/charging-station/Helpers.ts +++ b/src/charging-station/Helpers.ts @@ -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( - // 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) } } diff --git a/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts b/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts index eb591836..3db5a0a8 100644 --- a/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts +++ b/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts @@ -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 diff --git a/src/charging-station/ocpp/1.6/OCPP16RequestService.ts b/src/charging-station/ocpp/1.6/OCPP16RequestService.ts index 92fc90bd..b0c7bc0a 100644 --- a/src/charging-station/ocpp/1.6/OCPP16RequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16RequestService.ts @@ -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, diff --git a/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts b/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts index a5c9519a..104057c4 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts @@ -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, })) diff --git a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts index 21bcf31e..7cde8008 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts @@ -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 } diff --git a/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts b/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts index 159d9363..e4db4f3f 100644 --- a/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts +++ b/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts @@ -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, diff --git a/src/charging-station/ocpp/2.0/OCPP20Constants.ts b/src/charging-station/ocpp/2.0/OCPP20Constants.ts index 9640a22e..636932c4 100644 --- a/src/charging-station/ocpp/2.0/OCPP20Constants.ts +++ b/src/charging-station/ocpp/2.0/OCPP20Constants.ts @@ -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) { diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index f87b528a..671c212a 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -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> @@ -167,50 +134,52 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { this.incomingRequestHandlers = new Map([ [ 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 { 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 = + 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( + 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 { + 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 { 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 + ): 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 (promise: Promise, ms: number, label: string): Promise { + let timer: ReturnType + return Promise.race([ + promise.finally(() => { + clearTimeout(timer) + }), + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new Error(`${label} timed out after ${ms.toString()}ms`)) + }, ms) + }), + ]) +} diff --git a/src/charging-station/ocpp/2.0/OCPP20RequestService.ts b/src/charging-station/ocpp/2.0/OCPP20RequestService.ts index 7f051566..50a66cca 100644 --- a/src/charging-station/ocpp/2.0/OCPP20RequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20RequestService.ts @@ -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 } diff --git a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts index 36fe0e55..593f8905 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts @@ -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 diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index 2ea4623e..b9da34f2 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -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 { 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}` ) diff --git a/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts b/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts index 32716881..b1133868 100644 --- a/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts +++ b/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts @@ -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( + Object.keys(VARIABLE_REGISTRY).map(k => k.split('::')[0]) + ) + private readonly invalidVariables = new Set() // composite key (lower case) private readonly maxSetOverrides = new Map() // composite key (lower case) private readonly minSetOverrides = new Map() // 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([ - 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 diff --git a/src/charging-station/ocpp/2.0/__testable__/OCPP20RequestServiceTestable.ts b/src/charging-station/ocpp/2.0/__testable__/OCPP20RequestServiceTestable.ts index 1bbb20eb..81dd5f0c 100644 --- a/src/charging-station/ocpp/2.0/__testable__/OCPP20RequestServiceTestable.ts +++ b/src/charging-station/ocpp/2.0/__testable__/OCPP20RequestServiceTestable.ts @@ -187,7 +187,7 @@ export function createTestableRequestService ( // 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 { diff --git a/src/charging-station/ocpp/2.0/__testable__/index.ts b/src/charging-station/ocpp/2.0/__testable__/index.ts index 403fa328..6fd50748 100644 --- a/src/charging-station/ocpp/2.0/__testable__/index.ts +++ b/src/charging-station/ocpp/2.0/__testable__/index.ts @@ -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 + + handleRequestTriggerMessage: ( + chargingStation: ChargingStation, + commandPayload: OCPP20TriggerMessageRequest + ) => OCPP20TriggerMessageResponse + + handleRequestUnlockConnector: ( + chargingStation: ChargingStation, + commandPayload: OCPP20UnlockConnectorRequest + ) => Promise } /** @@ -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), } } diff --git a/src/charging-station/ocpp/OCPPServiceUtils.ts b/src/charging-station/ocpp/OCPPServiceUtils.ts index 47f67f4a..ffaf5107 100644 --- a/src/charging-station/ocpp/OCPPServiceUtils.ts +++ b/src/charging-station/ocpp/OCPPServiceUtils.ts @@ -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 => { + 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 = ( 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 = ( 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 } diff --git a/src/performance/storage/MikroOrmStorage.ts b/src/performance/storage/MikroOrmStorage.ts index dbf4ef6f..1a1d0d97 100644 --- a/src/performance/storage/MikroOrmStorage.ts +++ b/src/performance/storage/MikroOrmStorage.ts @@ -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) { diff --git a/src/types/index.ts b/src/types/index.ts index b3290bfa..de11ef99 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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' diff --git a/src/types/ocpp/1.6/Requests.ts b/src/types/ocpp/1.6/Requests.ts index b7a68402..644e0d4c 100644 --- a/src/types/ocpp/1.6/Requests.ts +++ b/src/types/ocpp/1.6/Requests.ts @@ -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', diff --git a/src/types/ocpp/1.6/Responses.ts b/src/types/ocpp/1.6/Responses.ts index d0fddace..6dc03580 100644 --- a/src/types/ocpp/1.6/Responses.ts +++ b/src/types/ocpp/1.6/Responses.ts @@ -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', diff --git a/src/types/ocpp/2.0/Common.ts b/src/types/ocpp/2.0/Common.ts index ff9c8fcd..ffec9145 100644 --- a/src/types/ocpp/2.0/Common.ts +++ b/src/types/ocpp/2.0/Common.ts @@ -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 diff --git a/src/types/ocpp/2.0/Requests.ts b/src/types/ocpp/2.0/Requests.ts index 5e0e38da..b7a9b952 100644 --- a/src/types/ocpp/2.0/Requests.ts +++ b/src/types/ocpp/2.0/Requests.ts @@ -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 +} diff --git a/src/types/ocpp/2.0/Responses.ts b/src/types/ocpp/2.0/Responses.ts index 3471ab8a..4a25491b 100644 --- a/src/types/ocpp/2.0/Responses.ts +++ b/src/types/ocpp/2.0/Responses.ts @@ -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 +} diff --git a/src/types/ocpp/2.0/Transaction.ts b/src/types/ocpp/2.0/Transaction.ts index ad5b849a..bf11c9d5 100644 --- a/src/types/ocpp/2.0/Transaction.ts +++ b/src/types/ocpp/2.0/Transaction.ts @@ -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', diff --git a/src/types/ocpp/2.0/index.ts b/src/types/ocpp/2.0/index.ts index fc681929..0d56994d 100644 --- a/src/types/ocpp/2.0/index.ts +++ b/src/types/ocpp/2.0/index.ts @@ -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' diff --git a/src/types/ocpp/ChargingProfile.ts b/src/types/ocpp/ChargingProfile.ts index 29cf7727..aaa9f1f5 100644 --- a/src/types/ocpp/ChargingProfile.ts +++ b/src/types/ocpp/ChargingProfile.ts @@ -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 = { diff --git a/src/types/ocpp/Common.ts b/src/types/ocpp/Common.ts index 4800dcd4..7190d9a4 100644 --- a/src/types/ocpp/Common.ts +++ b/src/types/ocpp/Common.ts @@ -1,6 +1,6 @@ import type { JsonObject } from '../JsonType.js' -export const enum GenericStatus { +export enum GenericStatus { Accepted = 'Accepted', Rejected = 'Rejected', } diff --git a/src/types/ocpp/Requests.ts b/src/types/ocpp/Requests.ts index 79bd00b3..f3a93972 100644 --- a/src/types/ocpp/Requests.ts +++ b/src/types/ocpp/Requests.ts @@ -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 diff --git a/src/types/ocpp/Responses.ts b/src/types/ocpp/Responses.ts index e6b88f00..ef7e95ed 100644 --- a/src/types/ocpp/Responses.ts +++ b/src/types/ocpp/Responses.ts @@ -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] diff --git a/tests/charging-station/ChargingStation-Connectors.test.ts b/tests/charging-station/ChargingStation-Connectors.test.ts index 4ea1112f..00776945 100644 --- a/tests/charging-station/ChargingStation-Connectors.test.ts +++ b/tests/charging-station/ChargingStation-Connectors.test.ts @@ -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) diff --git a/tests/charging-station/ChargingStation-Lifecycle.test.ts b/tests/charging-station/ChargingStation-Lifecycle.test.ts index 5672cafe..af03adac 100644 --- a/tests/charging-station/ChargingStation-Lifecycle.test.ts +++ b/tests/charging-station/ChargingStation-Lifecycle.test.ts @@ -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) }) diff --git a/tests/charging-station/ChargingStation-Resilience.test.ts b/tests/charging-station/ChargingStation-Resilience.test.ts index 166aeaa8..726e1404 100644 --- a/tests/charging-station/ChargingStation-Resilience.test.ts +++ b/tests/charging-station/ChargingStation-Resilience.test.ts @@ -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()}` ) }) diff --git a/tests/charging-station/ChargingStation-Transactions.test.ts b/tests/charging-station/ChargingStation-Transactions.test.ts index e7b6915d..24f42b45 100644 --- a/tests/charging-station/ChargingStation-Transactions.test.ts +++ b/tests/charging-station/ChargingStation-Transactions.test.ts @@ -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) { diff --git a/tests/charging-station/ChargingStation.test.ts b/tests/charging-station/ChargingStation.test.ts index d1c0b685..0c8cecf5 100644 --- a/tests/charging-station/ChargingStation.test.ts +++ b/tests/charging-station/ChargingStation.test.ts @@ -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 diff --git a/tests/charging-station/Helpers.test.ts b/tests/charging-station/Helpers.test.ts index a778d26b..b3747d37 100644 --- a/tests/charging-station/Helpers.test.ts +++ b/tests/charging-station/Helpers.test.ts @@ -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 diff --git a/tests/charging-station/helpers/StationHelpers.ts b/tests/charging-station/helpers/StationHelpers.ts index 35f4ea1c..3622e790 100644 --- a/tests/charging-station/helpers/StationHelpers.ts +++ b/tests/charging-station/helpers/StationHelpers.ts @@ -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 - /** 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 - sendError: () => Promise - sendResponse: () => Promise + requestHandler: (...args: unknown[]) => Promise + sendError: (...args: unknown[]) => Promise + sendResponse: (...args: unknown[]) => Promise } /** @@ -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 } diff --git a/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts b/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts index 7b3b4409..8d4f6c8c 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts @@ -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]+$/), diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts index 4416d5b5..bf936d4f 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts @@ -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 @@ -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, }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-DeleteCertificate.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-DeleteCertificate.test.ts index 5d0317b0..9a160c69 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-DeleteCertificate.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-DeleteCertificate.test.ts @@ -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 = { diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetVariables.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetVariables.test.ts index 9ff24a46..980653d8 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetVariables.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetVariables.test.ts @@ -36,7 +36,7 @@ import { } from './OCPP20TestUtils.js' await describe('B06 - Get Variables', async () => { - let station: ReturnType + let station: ReturnType['station'] let incomingRequestService: OCPP20IncomingRequestService beforeEach(() => { diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-InstallCertificate.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-InstallCertificate.test.ts index ff1ae48b..8a5ee14d 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-InstallCertificate.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-InstallCertificate.test.ts @@ -164,7 +164,7 @@ await describe('I03 - InstallCertificate', async () => { stationWithCertManager.certificateManager = createMockCertificateManager({ storeCertificateResult: false, }) - mockStation.stationInfo.validateCertificateExpiry = true + ;(mockStation.stationInfo as Record).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).validateCertificateExpiry }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RemoteStartAuth.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RemoteStartAuth.test.ts index f8c4ba4b..d06d9e5f 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RemoteStartAuth.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RemoteStartAuth.test.ts @@ -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() diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts index 2631febb..e4ceb85c 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts @@ -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, diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-Reset.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-Reset.test.ts index e95ef7d8..56f72e43 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-Reset.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-Reset.test.ts @@ -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 = { @@ -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 index 00000000..4cb765e5 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts @@ -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 +} { + 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 + + 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).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 index 00000000..5c39e736 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UnlockConnector.test.ts @@ -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 +} { + 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 + + 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).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 index 00000000..00bded37 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20Integration-Certificate.test.ts @@ -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 + + 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 index 00000000..261e2359 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20Integration.test.ts @@ -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 + + 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) + }) +}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-ISO15118.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-ISO15118.test.ts index 912d2a83..5b4e52de 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20RequestService-ISO15118.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20RequestService-ISO15118.test.ts @@ -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 + let station: ReturnType['station'] beforeEach(() => { const { station: newStation } = createMockChargingStation({ diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-NotifyReport.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-NotifyReport.test.ts index f9eea7ad..6abac45d 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20RequestService-NotifyReport.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20RequestService-NotifyReport.test.ts @@ -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', () => { diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-SignCertificate.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-SignCertificate.test.ts index ddd45116..6d67d71d 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20RequestService-SignCertificate.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20RequestService-SignCertificate.test.ts @@ -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({ 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 index 00000000..fcf4d6f0 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts @@ -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 { + await responseService.responseHandler( + mockStation, + OCPP20RequestCommand.TRANSACTION_EVENT, + payload as unknown as Parameters[2], + {} as Parameters[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: 'Session complete', + 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 index 00000000..277cdb8e --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20SchemaValidation.test.ts @@ -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 { + return JSON.parse(readFileSync(join(SCHEMA_DIR, filename), 'utf8')) as Record +} + +/** + * 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) + }) +}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts index 2c224551..6e9968f6 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts @@ -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).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).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).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).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 index 00000000..40851b64 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-enforceMessageLimits.test.ts @@ -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) + }) + }) +}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts b/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts index 09e1c02c..a92bc75b 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts @@ -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) => { - 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, + }) + 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) { diff --git a/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts b/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts index 87db619f..dc7eccff 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts @@ -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) diff --git a/tests/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.test.ts b/tests/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.test.ts index 94ac175a..bf5124f0 100644 --- a/tests/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.test.ts +++ b/tests/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.test.ts @@ -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 { diff --git a/tests/charging-station/ui-server/UIWebSocketServer.test.ts b/tests/charging-station/ui-server/UIWebSocketServer.test.ts index 09205138..11558288 100644 --- a/tests/charging-station/ui-server/UIWebSocketServer.test.ts +++ b/tests/charging-station/ui-server/UIWebSocketServer.test.ts @@ -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) }) -- 2.43.0