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