From e8d291b613ba556b76192faa25e25a31b30f3117 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Tue, 31 Mar 2026 20:51:32 +0200 Subject: [PATCH] fix(ocpp): correct P0/P1 spec conformity gaps for OCPP 1.6 and 2.0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Addresses 7 non-conformities identified during algorithmic conformity audit: P0 - OCPP-J protocol layer: - Add MessageTypeNotSupported and RpcFrameworkError error codes (2.0.1 §4.3) - Send CALLERROR with messageId "-1" on JSON parse failure (2.0.1 §4.2.3) - Implement RetryBackOff reconnection algorithm using spec variables (2.0.1 §8.1-§8.3) P1 - OCPP 1.6 logic fixes: - Update authorization cache on Authorize/Start/StopTransaction responses - Fix clearChargingProfiles to use AND logic per Errata 3.25 P1 - OCPP 2.0.1 security lifecycle: - Add certificate signing retry with exponential back-off (A02.FR.17-19) - Add security event notification queue with guaranteed delivery (A04.FR.02) - Update authorization cache on AuthorizeResponse (C10.FR.04) Infrastructure: - Consolidate exponential backoff into computeExponentialBackOffDelay() - Replace string literals with OCPP20OptionalVariableName enum entries - Export OCPP16IdTagInfo type, make getAuthCache() non-optional on AuthStrategy - Factor updateAuthorizationCache() into ServiceUtils for both 1.6 and 2.0 --- src/charging-station/ChargingStation.ts | 77 +++- .../ocpp/1.6/OCPP16ResponseService.ts | 53 ++- .../ocpp/1.6/OCPP16ServiceUtils.ts | 104 ++++-- .../ocpp/2.0/OCPP20CertSigningRetryManager.ts | 147 ++++++++ .../ocpp/2.0/OCPP20IncomingRequestService.ts | 120 +++++-- .../ocpp/2.0/OCPP20ResponseService.ts | 39 +- .../ocpp/2.0/OCPP20ServiceUtils.ts | 56 +++ .../ocpp/2.0/OCPP20VariableRegistry.ts | 79 ++-- .../ocpp/auth/interfaces/OCPPAuthService.ts | 10 +- .../ocpp/auth/services/OCPPAuthServiceImpl.ts | 15 +- .../strategies/CertificateAuthStrategy.ts | 4 + .../auth/strategies/RemoteAuthStrategy.ts | 4 + src/types/index.ts | 1 + src/types/ocpp/1.6/Transaction.ts | 18 +- src/types/ocpp/2.0/Variables.ts | 5 + src/types/ocpp/ErrorType.ts | 4 + src/utils/Utils.ts | 31 +- src/utils/index.ts | 2 +- .../ocpp/1.6/OCPP16ServiceUtils.test.ts | 239 +++++++++++- .../2.0/OCPP20CertSigningRetryManager.test.ts | 339 ++++++++++++++++++ .../OCPP20ResponseService-CacheUpdate.test.ts | 2 +- .../2.0/OCPP20ServiceUtils-AuthCache.test.ts | 123 +++++++ .../OCPP20ServiceUtils-ReconnectDelay.test.ts | 151 ++++++++ tests/utils/Utils.test.ts | 239 ++++++------ 24 files changed, 1590 insertions(+), 272 deletions(-) create mode 100644 src/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.test.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-AuthCache.test.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-ReconnectDelay.test.ts diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index 7552c4bd..14c68d7f 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -43,7 +43,7 @@ import { type IncomingRequestCommand, MessageType, MeterValueMeasurand, - type OCPPVersion, + OCPPVersion, type OutgoingRequest, PowerUnits, RegistrationStatusEnumType, @@ -77,6 +77,7 @@ import { buildUpdatedMessage, clampToSafeTimerValue, clone, + computeExponentialBackOffDelay, Configuration, Constants, convertToBoolean, @@ -84,7 +85,6 @@ import { convertToInt, DCElectricUtils, ensureError, - exponentialDelay, formatDurationMilliSeconds, formatDurationSeconds, getErrorMessage, @@ -148,6 +148,7 @@ import { buildBootNotificationRequest, createOCPPServices, flushQueuedTransactionMessages, + OCPP20ServiceUtils, OCPPAuthServiceFactory, type OCPPIncomingRequestService, type OCPPRequestService, @@ -1467,6 +1468,22 @@ export class ChargingStation extends EventEmitter { return powerDivider } + private getReconnectDelay (): number { + if ( + this.stationInfo?.ocppVersion === OCPPVersion.VERSION_20 || + this.stationInfo?.ocppVersion === OCPPVersion.VERSION_201 + ) { + return OCPP20ServiceUtils.computeReconnectDelay(this, this.wsConnectionRetryCount) + } + return this.stationInfo?.reconnectExponentialDelay === true + ? computeExponentialBackOffDelay({ + baseDelayMs: 100, + jitterPercent: 0.2, + retryNumber: this.wsConnectionRetryCount, + }) + : secondsToMilliseconds(Constants.DEFAULT_WS_RECONNECT_DELAY) + } + private getStationInfo (options?: ChargingStationOptions): ChargingStationInfo { const stationInfoFromTemplate = this.getStationInfoFromTemplate() options?.persistentConfiguration != null && @@ -2180,7 +2197,12 @@ export class ChargingStation extends EventEmitter { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions errorMsg = `Wrong message type ${messageType}` logger.error(`${this.logPrefix()} ${errorMsg}`) - throw new OCPPError(ErrorType.PROTOCOL_ERROR, errorMsg) + throw new OCPPError( + this.stationInfo?.ocppVersion !== OCPPVersion.VERSION_16 + ? ErrorType.MESSAGE_TYPE_NOT_SUPPORTED + : ErrorType.PROTOCOL_ERROR, + errorMsg + ) } } else { throw new OCPPError( @@ -2196,6 +2218,28 @@ export class ChargingStation extends EventEmitter { if (!Array.isArray(request)) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.error(`${this.logPrefix()} Incoming message '${request}' parsing error:`, error) + // OCPP 2.0.1 §4.2.3: respond with CALLERROR using messageId "-1" + if (this.stationInfo?.ocppVersion !== OCPPVersion.VERSION_16) { + await this.ocppRequestService + .sendError( + this, + '-1', + new OCPPError( + ErrorType.RPC_FRAMEWORK_ERROR, + 'Incoming message is not a valid JSON or not an array', + undefined, + // eslint-disable-next-line @typescript-eslint/no-base-to-string + { rawMessage: typeof data === 'string' ? data : data.toString() } + ), + Constants.UNKNOWN_OCPP_COMMAND + ) + .catch((sendError: unknown) => { + logger.error( + `${this.logPrefix()} Error sending RpcFrameworkError CALLERROR:`, + sendError + ) + }) + } return } let commandName: IncomingRequestCommand | undefined @@ -2278,12 +2322,14 @@ export class ChargingStation extends EventEmitter { if (!this.inAcceptedState()) { ++registrationRetryCount await sleep( - exponentialDelay( - registrationRetryCount, - this.bootNotificationResponse?.interval != null - ? secondsToMilliseconds(this.bootNotificationResponse.interval) - : Constants.DEFAULT_BOOT_NOTIFICATION_INTERVAL - ) + computeExponentialBackOffDelay({ + baseDelayMs: + this.bootNotificationResponse?.interval != null + ? secondsToMilliseconds(this.bootNotificationResponse.interval) + : Constants.DEFAULT_BOOT_NOTIFICATION_INTERVAL, + jitterMs: 1000, + retryNumber: registrationRetryCount, + }) ) } } while ( @@ -2322,10 +2368,7 @@ export class ChargingStation extends EventEmitter { this.wsConnectionRetryCount < (this.stationInfo?.autoReconnectMaxRetries ?? 0) ) { ++this.wsConnectionRetryCount - const reconnectDelay = - this.stationInfo?.reconnectExponentialDelay === true - ? exponentialDelay(this.wsConnectionRetryCount) - : secondsToMilliseconds(Constants.DEFAULT_WS_RECONNECT_DELAY) + const reconnectDelay = this.getReconnectDelay() const reconnectDelayWithdraw = 1000 const reconnectTimeout = reconnectDelay - reconnectDelayWithdraw > 0 ? reconnectDelay - reconnectDelayWithdraw : 0 @@ -2517,7 +2560,13 @@ export class ChargingStation extends EventEmitter { ) } // eslint-disable-next-line promise/no-promise-in-callback - sleep(exponentialDelay(messageIdx)) + sleep( + computeExponentialBackOffDelay({ + baseDelayMs: 100, + jitterPercent: 0.2, + retryNumber: messageIdx ?? 0, + }) + ) .then(() => { if (messageIdx != null) { ++messageIdx diff --git a/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts b/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts index a4a4b3d6..50f37e87 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts @@ -168,24 +168,23 @@ export class OCPP16ResponseService extends OCPPResponseService { } if (authorizeConnectorId != null) { const authorizeConnectorStatus = chargingStation.getConnectorStatus(authorizeConnectorId) - if (authorizeConnectorStatus == null) { - return - } - if (payload.idTagInfo.status === OCPP16AuthorizationStatus.ACCEPTED) { - authorizeConnectorStatus.idTagAuthorized = true - logger.debug( - `${chargingStation.logPrefix()} ${moduleName}.handleResponseAuthorize: idTag '${ - requestPayload.idTag - }' accepted on connector id ${authorizeConnectorId.toString()}` - ) - } else { - authorizeConnectorStatus.idTagAuthorized = false - delete authorizeConnectorStatus.authorizeIdTag - logger.debug( - `${chargingStation.logPrefix()} ${moduleName}.handleResponseAuthorize: idTag '${truncateId( - requestPayload.idTag - )}' rejected with status '${payload.idTagInfo.status}'` - ) + if (authorizeConnectorStatus != null) { + if (payload.idTagInfo.status === OCPP16AuthorizationStatus.ACCEPTED) { + authorizeConnectorStatus.idTagAuthorized = true + logger.debug( + `${chargingStation.logPrefix()} ${moduleName}.handleResponseAuthorize: idTag '${ + requestPayload.idTag + }' accepted on connector id ${authorizeConnectorId.toString()}` + ) + } else { + authorizeConnectorStatus.idTagAuthorized = false + delete authorizeConnectorStatus.authorizeIdTag + logger.debug( + `${chargingStation.logPrefix()} ${moduleName}.handleResponseAuthorize: idTag '${truncateId( + requestPayload.idTag + )}' rejected with status '${payload.idTagInfo.status}'` + ) + } } } else { logger.warn( @@ -194,6 +193,11 @@ export class OCPP16ResponseService extends OCPPResponseService { }' has no authorize request pending` ) } + OCPP16ServiceUtils.updateAuthorizationCache( + chargingStation, + requestPayload.idTag, + payload.idTagInfo + ) } private handleResponseBootNotification ( @@ -470,6 +474,11 @@ export class OCPP16ResponseService extends OCPPResponseService { ) await this.resetConnectorOnStartTransactionError(chargingStation, connectorId) } + OCPP16ServiceUtils.updateAuthorizationCache( + chargingStation, + requestPayload.idTag, + payload.idTagInfo + ) } private async handleResponseStopTransaction ( @@ -529,6 +538,7 @@ export class OCPP16ResponseService extends OCPPResponseService { } } const transactionConnectorStatus = chargingStation.getConnectorStatus(transactionConnectorId) + const transactionIdTag = requestPayload.idTag ?? transactionConnectorStatus?.transactionIdTag resetConnectorStatus(transactionConnectorStatus) if ( transactionConnectorStatus != null && @@ -550,6 +560,13 @@ export class OCPP16ResponseService extends OCPPResponseService { } else { logger.warn(logMsg) } + if (payload.idTagInfo != null && transactionIdTag != null) { + OCPP16ServiceUtils.updateAuthorizationCache( + chargingStation, + transactionIdTag, + payload.idTagInfo + ) + } } private async resetConnectorOnStartTransactionError ( diff --git a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts index d0fc1e26..d6faa6f8 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts @@ -29,6 +29,7 @@ import { type OCPP16ChargingProfile, type OCPP16ChargingSchedule, type OCPP16ClearChargingProfileRequest, + type OCPP16IdTagInfo, OCPP16IncomingRequestCommand, type OCPP16MeterValue, OCPP16MeterValueContext, @@ -55,7 +56,14 @@ import { isNotEmptyArray, logger, roundTo, + truncateId, } from '../../../utils/index.js' +import { + AuthenticationMethod, + type AuthorizationResult, + mapOCPP16Status, + OCPPAuthServiceFactory, +} from '../auth/index.js' import { buildEmptyMeterValue, buildMeterValue, @@ -304,39 +312,32 @@ export class OCPP16ServiceUtils { chargingProfiles: OCPP16ChargingProfile[] | undefined ): boolean => { const { chargingProfilePurpose, id, stackLevel } = commandPayload - let clearedCP = false + let profileCleared = false if (isNotEmptyArray(chargingProfiles)) { - chargingProfiles.forEach((chargingProfile: OCPP16ChargingProfile, index: number) => { - let clearCurrentCP = false - if (chargingProfile.chargingProfileId === id) { - clearCurrentCP = true - } - if (chargingProfilePurpose == null && chargingProfile.stackLevel === stackLevel) { - clearCurrentCP = true - } - if ( - stackLevel == null && - chargingProfile.chargingProfilePurpose === chargingProfilePurpose - ) { - clearCurrentCP = true - } - if ( - chargingProfile.stackLevel === stackLevel && - chargingProfile.chargingProfilePurpose === chargingProfilePurpose - ) { - clearCurrentCP = true - } - if (clearCurrentCP) { - chargingProfiles.splice(index, 1) - logger.debug( - `${chargingStation.logPrefix()} ${moduleName}.clearChargingProfiles: Matching charging profile(s) cleared: %j`, - chargingProfile - ) - clearedCP = true + // Errata 3.25: ALL specified fields must match (AND logic). + // null/undefined fields are wildcards (match any). + const unmatchedProfiles = chargingProfiles.filter( + (chargingProfile: OCPP16ChargingProfile) => { + const matchesId = id == null || chargingProfile.chargingProfileId === id + const matchesPurpose = + chargingProfilePurpose == null || + chargingProfile.chargingProfilePurpose === chargingProfilePurpose + const matchesStackLevel = stackLevel == null || chargingProfile.stackLevel === stackLevel + if (matchesId && matchesPurpose && matchesStackLevel) { + logger.debug( + `${chargingStation.logPrefix()} ${moduleName}.clearChargingProfiles: Matching charging profile(s) cleared: %j`, + chargingProfile + ) + profileCleared = true + return false + } + return true } - }) + ) + chargingProfiles.length = 0 + chargingProfiles.push(...unmatchedProfiles) } - return clearedCP + return profileCleared } /** @@ -855,6 +856,49 @@ export class OCPP16ServiceUtils { } } + public static updateAuthorizationCache ( + chargingStation: ChargingStation, + idTag: string, + idTagInfo: OCPP16IdTagInfo + ): void { + try { + const authService = OCPPAuthServiceFactory.getInstance(chargingStation) + const authCache = authService.getAuthCache() + if (authCache == null) { + return + } + const result: AuthorizationResult = { + isOffline: false, + method: AuthenticationMethod.REMOTE_AUTHORIZATION, + status: mapOCPP16Status(idTagInfo.status), + timestamp: new Date(), + } + let ttl: number | undefined + if (idTagInfo.expiryDate != null) { + const expiryDate = convertToDate(idTagInfo.expiryDate) + if (expiryDate != null) { + const ttlSeconds = Math.ceil((expiryDate.getTime() - Date.now()) / 1000) + if (ttlSeconds <= 0) { + logger.debug( + `${chargingStation.logPrefix()} ${moduleName}.updateAuthorizationCache: Skipping expired entry for '${truncateId(idTag)}'` + ) + return + } + ttl = ttlSeconds + } + } + authCache.set(idTag, result, ttl) + logger.debug( + `${chargingStation.logPrefix()} ${moduleName}.updateAuthorizationCache: Updated cache for '${truncateId(idTag)}' status=${result.status}${ttl != null ? `, ttl=${ttl.toString()}s` : ''}` + ) + } catch (error) { + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.updateAuthorizationCache: Cache update failed for '${truncateId(idTag)}':`, + error + ) + } + } + private static readonly composeChargingSchedule = ( chargingSchedule: OCPP16ChargingSchedule, compositeInterval: Interval diff --git a/src/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.ts b/src/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.ts new file mode 100644 index 00000000..c883d859 --- /dev/null +++ b/src/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.ts @@ -0,0 +1,147 @@ +import { secondsToMilliseconds } from 'date-fns' + +import type { ChargingStation } from '../../../charging-station/index.js' +import type { JsonType, OCPP20SignCertificateResponse } from '../../../types/index.js' + +import { + GenericStatus, + OCPP20ComponentName, + OCPP20OptionalVariableName, + OCPP20RequestCommand, +} from '../../../types/index.js' +import { computeExponentialBackOffDelay, logger } from '../../../utils/index.js' +import { OCPP20VariableManager } from './OCPP20VariableManager.js' + +const moduleName = 'OCPP20CertSigningRetryManager' + +/** + * Manages certificate signing retry with exponential back-off per OCPP 2.0.1 A02.FR.17-19. + * + * After a SignCertificateResponse(Accepted), starts a timer. If no CertificateSignedRequest + * is received before CertSigningWaitMinimum expires, sends a new SignCertificateRequest with + * doubled back-off, up to CertSigningRepeatTimes attempts. + */ +export class OCPP20CertSigningRetryManager { + private retryAborted = false + private retryCount = 0 + private retryTimer: ReturnType | undefined + + constructor (private readonly chargingStation: ChargingStation) {} + + /** + * Cancel any pending retry timer. + * Called when CertificateSignedRequest is received (A02.FR.20) or station stops. + */ + public cancelRetryTimer (): void { + this.retryAborted = true + if (this.retryTimer != null) { + clearTimeout(this.retryTimer) + this.retryTimer = undefined + } + this.retryCount = 0 + logger.debug( + `${this.chargingStation.logPrefix()} ${moduleName}.cancelRetryTimer: Retry timer cancelled` + ) + } + + /** + * Start the retry back-off timer after SignCertificateResponse(Accepted). + * @param certificateType - Optional certificate type for re-signing + */ + public startRetryTimer (certificateType?: string): void { + this.cancelRetryTimer() + this.retryAborted = false + const waitMinimum = this.getVariableValue(OCPP20OptionalVariableName.CertSigningWaitMinimum) + if (waitMinimum == null || waitMinimum <= 0) { + logger.warn( + `${this.chargingStation.logPrefix()} ${moduleName}.startRetryTimer: ${OCPP20OptionalVariableName.CertSigningWaitMinimum} not configured or invalid, retry disabled` + ) + return + } + logger.debug( + `${this.chargingStation.logPrefix()} ${moduleName}.startRetryTimer: Starting cert signing retry timer with initial backoff ${waitMinimum.toString()}s` + ) + this.scheduleNextRetry(certificateType, waitMinimum) + } + + private getVariableValue (variableName: OCPP20OptionalVariableName): number | undefined { + const variableManager = OCPP20VariableManager.getInstance() + const results = variableManager.getVariables(this.chargingStation, [ + { + component: { name: OCPP20ComponentName.SecurityCtrlr }, + variable: { name: variableName }, + }, + ]) + if (results.length > 0 && results[0]?.attributeValue != null) { + const parsed = parseInt(results[0].attributeValue, 10) + return Number.isNaN(parsed) ? undefined : parsed + } + return undefined + } + + private scheduleNextRetry (certificateType?: string, waitMinimumSeconds?: number): void { + const maxRetries = this.getVariableValue(OCPP20OptionalVariableName.CertSigningRepeatTimes) ?? 0 + if (this.retryCount >= maxRetries) { + logger.warn( + `${this.chargingStation.logPrefix()} ${moduleName}.scheduleNextRetry: Max retry count ${maxRetries.toString()} reached, giving up` + ) + this.retryCount = 0 + return + } + + const baseDelayMs = secondsToMilliseconds( + waitMinimumSeconds ?? + this.getVariableValue(OCPP20OptionalVariableName.CertSigningWaitMinimum) ?? + 60 + ) + const delayMs = computeExponentialBackOffDelay({ + baseDelayMs, + maxRetries, + retryNumber: this.retryCount, + }) + + logger.debug( + `${this.chargingStation.logPrefix()} ${moduleName}.scheduleNextRetry: Scheduling retry ${(this.retryCount + 1).toString()}/${maxRetries.toString()} in ${Math.round(delayMs / 1000).toString()}s` + ) + + this.retryTimer = setTimeout(() => { + this.retryTimer = undefined + this.retryCount++ + logger.info( + `${this.chargingStation.logPrefix()} ${moduleName}.scheduleNextRetry: Sending SignCertificateRequest retry ${this.retryCount.toString()}/${maxRetries.toString()}` + ) + + this.chargingStation.ocppRequestService + .requestHandler( + this.chargingStation, + OCPP20RequestCommand.SIGN_CERTIFICATE, + certificateType != null ? { certificateType } : {}, + { skipBufferingOnError: true } + ) + .then(response => { + if (this.retryAborted) { + return undefined + } + if (response.status === GenericStatus.Accepted) { + this.scheduleNextRetry(certificateType) + } else { + logger.warn( + `${this.chargingStation.logPrefix()} ${moduleName}.scheduleNextRetry: SignCertificate retry rejected by CSMS, stopping retries` + ) + this.retryCount = 0 + } + return undefined + }) + .catch((error: unknown) => { + if (this.retryAborted) { + return + } + logger.error( + `${this.chargingStation.logPrefix()} ${moduleName}.scheduleNextRetry: SignCertificate retry failed`, + error + ) + this.scheduleNextRetry(certificateType) + }) + }, delayMs) + } +} diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index aff6b6e0..9b0df3f6 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -160,6 +160,7 @@ import { hasCertificateManager, type StoreCertificateResult, } from './OCPP20CertificateManager.js' +import { OCPP20CertSigningRetryManager } from './OCPP20CertSigningRetryManager.js' import { OCPP20Constants } from './OCPP20Constants.js' import { OCPP20ServiceUtils } from './OCPP20ServiceUtils.js' import { OCPP20VariableManager } from './OCPP20VariableManager.js' @@ -211,8 +212,18 @@ const buildStationInfoReportData = ( interface OCPP20StationState { activeFirmwareUpdateAbortController?: AbortController activeFirmwareUpdateRequestId?: number + certSigningRetryManager?: OCPP20CertSigningRetryManager + isDrainingSecurityEvents: boolean preInoperativeConnectorStatuses: Map reportDataCache: Map + securityEventQueue: QueuedSecurityEvent[] +} + +interface QueuedSecurityEvent { + retryCount?: number + techInfo?: string + timestamp: Date + type: string } export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { @@ -575,6 +586,14 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { ) } + public getCertSigningRetryManager ( + chargingStation: ChargingStation + ): OCPP20CertSigningRetryManager { + const state = this.getStationState(chargingStation) + state.certSigningRetryManager ??= new OCPP20CertSigningRetryManager(chargingStation) + return state.certSigningRetryManager + } + /** * Handle OCPP 2.0.1 GetVariables request from the CSMS. * @param chargingStation - Target charging station @@ -1119,8 +1138,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { let state = this.stationsState.get(chargingStation) if (state == null) { state = { + isDrainingSecurityEvents: false, preInoperativeConnectorStatuses: new Map(), reportDataCache: new Map(), + securityEventQueue: [], } this.stationsState.set(chargingStation, state) } @@ -1339,12 +1360,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { chargingStation, 'InvalidChargingStationCertificate', `X.509 validation failed: ${x509Result.reason ?? 'Unknown'}` - ).catch((error: unknown) => { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestCertificateSigned: SecurityEventNotification failed:`, - error - ) - }) + ) return { status: GenericStatus.Rejected, statusInfo: { @@ -1390,6 +1406,8 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { logger.info( `${chargingStation.logPrefix()} ${moduleName}.handleRequestCertificateSigned: Certificate chain stored successfully` ) + // A02.FR.20: Cancel retry timer when CertificateSignedRequest is received and accepted + this.getCertSigningRetryManager(chargingStation).cancelRetryTimer() return { status: GenericStatus.Accepted, } @@ -2825,12 +2843,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { chargingStation, 'InvalidFirmwareSigningCertificate', `Invalid signing certificate PEM for requestId ${requestId.toString()}` - ).catch((error: unknown) => { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestUpdateFirmware: SecurityEventNotification error:`, - error - ) - }) + ) return { status: UpdateFirmwareStatusEnumType.InvalidCertificate, } @@ -3318,6 +3331,67 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { stationState.reportDataCache.delete(requestId) } + private sendQueuedSecurityEvents (chargingStation: ChargingStation): void { + const stationState = this.getStationState(chargingStation) + if ( + stationState.isDrainingSecurityEvents || + !chargingStation.isWebSocketConnectionOpened() || + stationState.securityEventQueue.length === 0 + ) { + return + } + stationState.isDrainingSecurityEvents = true + const queue = stationState.securityEventQueue + logger.info( + `${chargingStation.logPrefix()} ${moduleName}.sendQueuedSecurityEvents: Draining ${queue.length.toString()} queued security event(s)` + ) + const drainNextEvent = (): void => { + if (queue.length === 0 || !chargingStation.isWebSocketConnectionOpened()) { + stationState.isDrainingSecurityEvents = false + return + } + const event = queue.shift() + if (event == null) { + stationState.isDrainingSecurityEvents = false + return + } + chargingStation.ocppRequestService + .requestHandler< + OCPP20SecurityEventNotificationRequest, + OCPP20SecurityEventNotificationResponse + >(chargingStation, OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION, { + timestamp: event.timestamp, + type: event.type, + ...(event.techInfo !== undefined && { techInfo: event.techInfo }), + }) + .then(() => { + drainNextEvent() + return undefined + }) + .catch((error: unknown) => { + const retryCount = (event.retryCount ?? 0) + 1 + if (retryCount >= 3) { + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.sendQueuedSecurityEvents: Discarding event '${event.type}' after ${retryCount.toString()} failed attempts`, + error + ) + drainNextEvent() + return + } + logger.error( + `${chargingStation.logPrefix()} ${moduleName}.sendQueuedSecurityEvents: Failed to send queued event '${event.type}' (attempt ${retryCount.toString()}/3)`, + error + ) + queue.unshift({ ...event, retryCount }) + stationState.isDrainingSecurityEvents = false + setTimeout(() => { + this.sendQueuedSecurityEvents(chargingStation) + }, 5000) + }) + } + drainNextEvent() + } + private sendRestoredAllConnectorsStatusNotifications (chargingStation: ChargingStation): void { for (const { connectorId } of chargingStation.iterateConnectors(true)) { const restoredStatus = this.getRestoredConnectorStatus(chargingStation, connectorId) @@ -3358,15 +3432,16 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { chargingStation: ChargingStation, type: string, techInfo?: string - ): Promise { - return chargingStation.ocppRequestService.requestHandler< - OCPP20SecurityEventNotificationRequest, - OCPP20SecurityEventNotificationResponse - >(chargingStation, OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION, { + ): void { + logger.info( + `${chargingStation.logPrefix()} ${moduleName}.sendSecurityEventNotification: [SecurityEvent] type=${type}${techInfo != null ? `, techInfo=${techInfo}` : ''}` + ) + this.getStationState(chargingStation).securityEventQueue.push({ timestamp: new Date(), type, ...(techInfo !== undefined && { techInfo }), }) + this.sendQueuedSecurityEvents(chargingStation) } /** @@ -3479,16 +3554,11 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { OCPP20FirmwareStatusEnumType.InvalidSignature, requestId ) - await this.sendSecurityEventNotification( + this.sendSecurityEventNotification( chargingStation, 'InvalidFirmwareSignature', `Firmware signature verification failed for requestId ${requestId.toString()}` - ).catch((error: unknown) => { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.simulateFirmwareUpdateLifecycle: SecurityEventNotification error:`, - error - ) - }) + ) logger.warn( `${chargingStation.logPrefix()} ${moduleName}.simulateFirmwareUpdateLifecycle: Firmware signature verification failed for requestId ${requestId.toString()} (simulated)` ) @@ -3574,7 +3644,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { ) // H11: Send SecurityEventNotification for successful firmware update - await this.sendSecurityEventNotification( + this.sendSecurityEventNotification( chargingStation, 'FirmwareUpdated', `Firmware update completed for requestId ${requestId.toString()}` diff --git a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts index c0d970c5..4e3c92df 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts @@ -8,8 +8,10 @@ import { import { ChargingStationEvents, ConnectorStatusEnum, + GenericStatus, type JsonType, OCPP20AuthorizationStatusEnumType, + type OCPP20AuthorizeRequest, type OCPP20AuthorizeResponse, type OCPP20BootNotificationResponse, OCPP20ComponentName, @@ -26,6 +28,7 @@ import { OCPP20OptionalVariableName, OCPP20RequestCommand, type OCPP20SecurityEventNotificationResponse, + type OCPP20SignCertificateRequest, type OCPP20SignCertificateResponse, type OCPP20StatusNotificationRequest, type OCPP20StatusNotificationResponse, @@ -38,13 +41,13 @@ import { type ResponseHandler, } from '../../../types/index.js' import { convertToDate, logger } from '../../../utils/index.js' -import { mapOCPP20TokenType, OCPPAuthServiceFactory } from '../auth/index.js' import { OCPPResponseService } from '../OCPPResponseService.js' import { createPayloadValidatorMap, isRequestCommandSupported, sendAndSetConnectorStatus, } from '../OCPPServiceUtils.js' +import { OCPP20IncomingRequestService } from './OCPP20IncomingRequestService.js' import { OCPP20ServiceUtils } from './OCPP20ServiceUtils.js' const moduleName = 'OCPP20ResponseService' @@ -195,11 +198,18 @@ export class OCPP20ResponseService extends OCPPResponseService { private handleResponseAuthorize ( chargingStation: ChargingStation, - payload: OCPP20AuthorizeResponse + payload: OCPP20AuthorizeResponse, + requestPayload: OCPP20AuthorizeRequest ): void { logger.debug( `${chargingStation.logPrefix()} ${moduleName}.handleResponseAuthorize: Authorize response received, status: ${payload.idTokenInfo.status}` ) + // C10.FR.04: Update Authorization Cache entry upon receipt of AuthorizationResponse + OCPP20ServiceUtils.updateAuthorizationCache( + chargingStation, + requestPayload.idToken, + payload.idTokenInfo + ) } private handleResponseBootNotification ( @@ -347,11 +357,17 @@ export class OCPP20ResponseService extends OCPPResponseService { private handleResponseSignCertificate ( chargingStation: ChargingStation, - payload: OCPP20SignCertificateResponse + payload: OCPP20SignCertificateResponse, + requestPayload: OCPP20SignCertificateRequest ): void { logger.debug( `${chargingStation.logPrefix()} ${moduleName}.handleResponseSignCertificate: SignCertificate response received, status: ${payload.status}` ) + if (payload.status === GenericStatus.Accepted) { + OCPP20IncomingRequestService.getInstance() + .getCertSigningRetryManager(chargingStation) + .startRetryTimer(requestPayload.certificateType) + } } private handleResponseStatusNotification ( @@ -479,18 +495,11 @@ export class OCPP20ResponseService extends OCPPResponseService { } // C10.FR.01/04/05: Update auth cache with idTokenInfo from response if (requestPayload.idToken != null) { - const idTokenValue = requestPayload.idToken.idToken - const idTokenInfo = payload.idTokenInfo - const identifierType = mapOCPP20TokenType(requestPayload.idToken.type) - try { - const authService = OCPPAuthServiceFactory.getInstance(chargingStation) - authService.updateCacheEntry(idTokenValue, idTokenInfo, identifierType) - } catch (error: unknown) { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Error updating auth cache:`, - error - ) - } + OCPP20ServiceUtils.updateAuthorizationCache( + chargingStation, + requestPayload.idToken, + payload.idTokenInfo + ) } } if (payload.updatedPersonalMessage != null) { diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index de52832c..19cdb4bd 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -13,6 +13,8 @@ import { OCPP20ComponentName, type OCPP20ConnectorStatusEnumType, type OCPP20EVSEType, + type OCPP20IdTokenInfoType, + type OCPP20IdTokenType, OCPP20IncomingRequestCommand, type OCPP20MeterValue, OCPP20OptionalVariableName, @@ -34,6 +36,7 @@ import { } from '../../../types/index.js' import { clampToSafeTimerValue, + computeExponentialBackOffDelay, Constants, convertToBoolean, convertToInt, @@ -44,6 +47,7 @@ import { validateIdentifierString, } from '../../../utils/index.js' import { buildConfigKey, getConfigurationKey } from '../../ConfigurationKeyUtils.js' +import { mapOCPP20TokenType, OCPPAuthServiceFactory } from '../auth/index.js' import { buildMeterValue, createPayloadConfigs, @@ -216,6 +220,42 @@ export class OCPP20ServiceUtils { } as unknown as OCPP20StatusNotificationRequest) } + /** + * OCPP 2.0.1 §8.1-§8.3 RetryBackOff reconnection delay computation. + * @param chargingStation - Target charging station + * @param retryCount - Current websocket connection retry count + * @returns Reconnect delay in milliseconds + */ + public static computeReconnectDelay ( + chargingStation: ChargingStation, + retryCount: number + ): number { + const variableManager = OCPP20VariableManager.getInstance() + const results = variableManager.getVariables(chargingStation, [ + { + component: { name: OCPP20ComponentName.OCPPCommCtrlr }, + variable: { name: OCPP20OptionalVariableName.RetryBackOffWaitMinimum }, + }, + { + component: { name: OCPP20ComponentName.OCPPCommCtrlr }, + variable: { name: OCPP20OptionalVariableName.RetryBackOffRandomRange }, + }, + { + component: { name: OCPP20ComponentName.OCPPCommCtrlr }, + variable: { name: OCPP20OptionalVariableName.RetryBackOffRepeatTimes }, + }, + ]) + const waitMinimum = convertToInt(results[0]?.attributeValue) || 30 + const randomRange = convertToInt(results[1]?.attributeValue) || 10 + const repeatTimes = convertToInt(results[2]?.attributeValue) || 5 + return computeExponentialBackOffDelay({ + baseDelayMs: secondsToMilliseconds(waitMinimum), + jitterMs: secondsToMilliseconds(randomRange), + maxRetries: repeatTimes, + retryNumber: Math.max(0, retryCount - 1), + }) + } + /** * OCPP 2.0 Incoming Request Service validator configurations * @returns Array of validator configuration tuples @@ -961,6 +1001,22 @@ export class OCPP20ServiceUtils { } } + public static updateAuthorizationCache ( + chargingStation: ChargingStation, + idToken: OCPP20IdTokenType, + idTokenInfo: OCPP20IdTokenInfoType + ): void { + try { + const authService = OCPPAuthServiceFactory.getInstance(chargingStation) + authService.updateCacheEntry(idToken.idToken, idTokenInfo, mapOCPP20TokenType(idToken.type)) + } catch (error: unknown) { + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.updateAuthorizationCache: Error updating auth cache:`, + error + ) + } + } + private static buildTransactionEndedMeterValues ( chargingStation: ChargingStation, connectorId: number, diff --git a/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts b/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts index b8109c5b..dfc71424 100644 --- a/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts +++ b/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts @@ -1406,53 +1406,62 @@ export const VARIABLE_REGISTRY: Record = { supportedAttributes: [AttributeEnumType.Actual], variable: 'QueueAllMessages', }, - [buildRegistryKey(OCPP20ComponentName.OCPPCommCtrlr, 'RetryBackOffRandomRange')]: { + [buildRegistryKey( + OCPP20ComponentName.OCPPCommCtrlr, + OCPP20OptionalVariableName.HeartbeatInterval + )]: { component: OCPP20ComponentName.OCPPCommCtrlr, dataType: DataEnumType.integer, - description: - 'When the Charging Station is reconnecting, after a connection loss, it will use this variable as the maximum value for the random part of the back-off time', + defaultValue: millisecondsToSeconds(Constants.DEFAULT_HEARTBEAT_INTERVAL).toString(), + description: 'Interval between Heartbeat messages.', + max: 86400, + maxLength: 10, + min: 1, mutability: MutabilityEnumType.ReadWrite, persistence: PersistenceEnumType.Persistent, + positive: true, supportedAttributes: [AttributeEnumType.Actual], - variable: 'RetryBackOffRandomRange', + unit: OCPP20UnitEnumType.SECONDS, + variable: OCPP20OptionalVariableName.HeartbeatInterval, }, - [buildRegistryKey(OCPP20ComponentName.OCPPCommCtrlr, 'RetryBackOffRepeatTimes')]: { + [buildRegistryKey( + OCPP20ComponentName.OCPPCommCtrlr, + OCPP20OptionalVariableName.RetryBackOffRandomRange + )]: { component: OCPP20ComponentName.OCPPCommCtrlr, dataType: DataEnumType.integer, description: - 'When the Charging Station is reconnecting, after a connection loss, it will use this variable for the amount of times it will double the previous back-off time.', + 'When the Charging Station is reconnecting, after a connection loss, it will use this variable as the maximum value for the random part of the back-off time', mutability: MutabilityEnumType.ReadWrite, persistence: PersistenceEnumType.Persistent, supportedAttributes: [AttributeEnumType.Actual], - variable: 'RetryBackOffRepeatTimes', + variable: OCPP20OptionalVariableName.RetryBackOffRandomRange, }, - [buildRegistryKey(OCPP20ComponentName.OCPPCommCtrlr, 'RetryBackOffWaitMinimum')]: { + [buildRegistryKey( + OCPP20ComponentName.OCPPCommCtrlr, + OCPP20OptionalVariableName.RetryBackOffRepeatTimes + )]: { component: OCPP20ComponentName.OCPPCommCtrlr, dataType: DataEnumType.integer, description: - 'When the Charging Station is reconnecting, after a connection loss, it will use this variable as the minimum back-off time, the first time it tries to reconnect.', + 'When the Charging Station is reconnecting, after a connection loss, it will use this variable for the amount of times it will double the previous back-off time.', mutability: MutabilityEnumType.ReadWrite, persistence: PersistenceEnumType.Persistent, supportedAttributes: [AttributeEnumType.Actual], - variable: 'RetryBackOffWaitMinimum', + variable: OCPP20OptionalVariableName.RetryBackOffRepeatTimes, }, [buildRegistryKey( OCPP20ComponentName.OCPPCommCtrlr, - OCPP20OptionalVariableName.HeartbeatInterval + OCPP20OptionalVariableName.RetryBackOffWaitMinimum )]: { component: OCPP20ComponentName.OCPPCommCtrlr, dataType: DataEnumType.integer, - defaultValue: millisecondsToSeconds(Constants.DEFAULT_HEARTBEAT_INTERVAL).toString(), - description: 'Interval between Heartbeat messages.', - max: 86400, - maxLength: 10, - min: 1, + description: + 'When the Charging Station is reconnecting, after a connection loss, it will use this variable as the minimum back-off time, the first time it tries to reconnect.', mutability: MutabilityEnumType.ReadWrite, persistence: PersistenceEnumType.Persistent, - positive: true, supportedAttributes: [AttributeEnumType.Actual], - unit: OCPP20UnitEnumType.SECONDS, - variable: OCPP20OptionalVariableName.HeartbeatInterval, + variable: OCPP20OptionalVariableName.RetryBackOffWaitMinimum, }, [buildRegistryKey( OCPP20ComponentName.OCPPCommCtrlr, @@ -1906,7 +1915,19 @@ export const VARIABLE_REGISTRY: Record = { supportedAttributes: [AttributeEnumType.Actual], variable: 'BasicAuthPassword', }, - [buildRegistryKey(OCPP20ComponentName.SecurityCtrlr, 'CertSigningRepeatTimes')]: { + [buildRegistryKey(OCPP20ComponentName.SecurityCtrlr, 'Identity')]: { + component: OCPP20ComponentName.SecurityCtrlr, + dataType: DataEnumType.string, + description: 'The Charging Station identity.', + mutability: MutabilityEnumType.ReadWrite, + persistence: PersistenceEnumType.Persistent, + supportedAttributes: [AttributeEnumType.Actual], + variable: 'Identity', + }, + [buildRegistryKey( + OCPP20ComponentName.SecurityCtrlr, + OCPP20OptionalVariableName.CertSigningRepeatTimes + )]: { component: OCPP20ComponentName.SecurityCtrlr, dataType: DataEnumType.integer, defaultValue: '3', @@ -1916,9 +1937,12 @@ export const VARIABLE_REGISTRY: Record = { mutability: MutabilityEnumType.ReadWrite, persistence: PersistenceEnumType.Persistent, supportedAttributes: [AttributeEnumType.Actual], - variable: 'CertSigningRepeatTimes', + variable: OCPP20OptionalVariableName.CertSigningRepeatTimes, }, - [buildRegistryKey(OCPP20ComponentName.SecurityCtrlr, 'CertSigningWaitMinimum')]: { + [buildRegistryKey( + OCPP20ComponentName.SecurityCtrlr, + OCPP20OptionalVariableName.CertSigningWaitMinimum + )]: { component: OCPP20ComponentName.SecurityCtrlr, dataType: DataEnumType.integer, defaultValue: '60', @@ -1929,16 +1953,7 @@ export const VARIABLE_REGISTRY: Record = { persistence: PersistenceEnumType.Persistent, supportedAttributes: [AttributeEnumType.Actual], unit: OCPP20UnitEnumType.SECONDS, - variable: 'CertSigningWaitMinimum', - }, - [buildRegistryKey(OCPP20ComponentName.SecurityCtrlr, 'Identity')]: { - component: OCPP20ComponentName.SecurityCtrlr, - dataType: DataEnumType.string, - description: 'The Charging Station identity.', - mutability: MutabilityEnumType.ReadWrite, - persistence: PersistenceEnumType.Persistent, - supportedAttributes: [AttributeEnumType.Actual], - variable: 'Identity', + variable: OCPP20OptionalVariableName.CertSigningWaitMinimum, }, [buildRegistryKey( OCPP20ComponentName.SecurityCtrlr, diff --git a/src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts b/src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts index d7782dfc..e04e0c13 100644 --- a/src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts +++ b/src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts @@ -158,9 +158,9 @@ export interface AuthStrategy { /** * Get the authorization cache if available - * @returns The authorization cache, or undefined if not available + * @returns The authorization cache, or undefined if caching is disabled or unavailable */ - getAuthCache?(): AuthCache | undefined + getAuthCache(): AuthCache | undefined /** * Get strategy-specific statistics @@ -402,6 +402,12 @@ export interface OCPPAuthService { */ clearCache(): void + /** + * Get the authorization cache if available + * @returns The authorization cache, or undefined if caching is disabled or unavailable + */ + getAuthCache(): AuthCache | undefined + /** * Get current authentication configuration */ diff --git a/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts b/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts index 655e6c89..d257dfe2 100644 --- a/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts +++ b/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts @@ -13,6 +13,7 @@ import { import { type ChargingStation } from '../../../index.js' import { AuthComponentFactory } from '../factories/AuthComponentFactory.js' import { + type AuthCache, type AuthStats, type AuthStrategy, type OCPPAuthService, @@ -280,7 +281,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { // Clear cache in local strategy const localStrategy = this.strategies.get('local') - const localAuthCache = localStrategy?.getAuthCache?.() + const localAuthCache = localStrategy?.getAuthCache() if (localAuthCache) { localAuthCache.clear() logger.info( @@ -293,6 +294,14 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { } } + public getAuthCache (): AuthCache | undefined { + if (!this.config.authorizationCacheEnabled) { + return undefined + } + const localStrategy = this.strategies.get('local') + return localStrategy?.getAuthCache() + } + /** * Get authentication statistics * @returns Authentication statistics including version and supported identifier types @@ -428,7 +437,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { // Invalidate in local strategy const localStrategy = this.strategies.get('local') - const localAuthCache = localStrategy?.getAuthCache?.() + const localAuthCache = localStrategy?.getAuthCache() if (localAuthCache) { localAuthCache.remove(identifier.value) logger.info( @@ -543,7 +552,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { } const localStrategy = this.strategies.get('local') - const authCache = localStrategy?.getAuthCache?.() + const authCache = localStrategy?.getAuthCache() if (authCache == null) { logger.debug( `${this.chargingStation.logPrefix()} ${moduleName}.updateCacheEntry: No auth cache available` diff --git a/src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts b/src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts index 7452a93f..22798c15 100644 --- a/src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts +++ b/src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts @@ -132,6 +132,10 @@ export class CertificateAuthStrategy implements AuthStrategy { logger.debug(`${moduleName}: Certificate authentication strategy cleaned up`) } + public getAuthCache (): undefined { + return undefined + } + getStats (): JsonObject { return { ...this.stats, diff --git a/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts b/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts index acf185a6..898d78a4 100644 --- a/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts +++ b/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts @@ -219,6 +219,10 @@ export class RemoteAuthStrategy implements AuthStrategy { logger.debug(`${moduleName}: Cleared OCPP adapter`) } + public getAuthCache (): undefined { + return undefined + } + /** * Get strategy statistics * @returns Strategy statistics including success rates, response times, and error counts diff --git a/src/types/index.ts b/src/types/index.ts index aa893493..1d41b5a4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -144,6 +144,7 @@ export { OCPP16AuthorizationStatus, type OCPP16AuthorizeRequest, type OCPP16AuthorizeResponse, + type OCPP16IdTagInfo, type OCPP16StartTransactionRequest, type OCPP16StartTransactionResponse, OCPP16StopTransactionReason, diff --git a/src/types/ocpp/1.6/Transaction.ts b/src/types/ocpp/1.6/Transaction.ts index 2aa5db38..6f87aeb8 100644 --- a/src/types/ocpp/1.6/Transaction.ts +++ b/src/types/ocpp/1.6/Transaction.ts @@ -28,7 +28,13 @@ export interface OCPP16AuthorizeRequest extends JsonObject { } export interface OCPP16AuthorizeResponse extends JsonObject { - idTagInfo: IdTagInfo + idTagInfo: OCPP16IdTagInfo +} + +export interface OCPP16IdTagInfo extends JsonObject { + expiryDate?: Date + parentIdTag?: string + status: OCPP16AuthorizationStatus } export interface OCPP16StartTransactionRequest extends JsonObject { @@ -40,7 +46,7 @@ export interface OCPP16StartTransactionRequest extends JsonObject { } export interface OCPP16StartTransactionResponse extends JsonObject { - idTagInfo: IdTagInfo + idTagInfo: OCPP16IdTagInfo transactionId: number } @@ -54,11 +60,5 @@ export interface OCPP16StopTransactionRequest extends JsonObject { } export interface OCPP16StopTransactionResponse extends JsonObject { - idTagInfo?: IdTagInfo -} - -interface IdTagInfo extends JsonObject { - expiryDate?: Date - parentIdTag?: string - status: OCPP16AuthorizationStatus + idTagInfo?: OCPP16IdTagInfo } diff --git a/src/types/ocpp/2.0/Variables.ts b/src/types/ocpp/2.0/Variables.ts index 1a8608ab..9c59e0f6 100644 --- a/src/types/ocpp/2.0/Variables.ts +++ b/src/types/ocpp/2.0/Variables.ts @@ -33,12 +33,17 @@ export enum OCPP20DeviceInfoVariableName { } export enum OCPP20OptionalVariableName { + CertSigningRepeatTimes = 'CertSigningRepeatTimes', + CertSigningWaitMinimum = 'CertSigningWaitMinimum', ConfigurationValueSize = 'ConfigurationValueSize', HeartbeatInterval = 'HeartbeatInterval', MaxCertificateChainSize = 'MaxCertificateChainSize', MaxEnergyOnInvalidId = 'MaxEnergyOnInvalidId', NonEvseSpecific = 'NonEvseSpecific', ReportingValueSize = 'ReportingValueSize', + RetryBackOffRandomRange = 'RetryBackOffRandomRange', + RetryBackOffRepeatTimes = 'RetryBackOffRepeatTimes', + RetryBackOffWaitMinimum = 'RetryBackOffWaitMinimum', ValueSize = 'ValueSize', WebSocketPingInterval = 'WebSocketPingInterval', } diff --git a/src/types/ocpp/ErrorType.ts b/src/types/ocpp/ErrorType.ts index 40a6e735..9fbb5e73 100644 --- a/src/types/ocpp/ErrorType.ts +++ b/src/types/ocpp/ErrorType.ts @@ -7,6 +7,8 @@ export enum ErrorType { GENERIC_ERROR = 'GenericError', // An internal error occurred and the receiver was not able to process the requested Action successfully INTERNAL_ERROR = 'InternalError', + // Requested MessageType is not supported by receiver (OCPP 2.0.1 §4.3 Table 8) + MESSAGE_TYPE_NOT_SUPPORTED = 'MessageTypeNotSupported', // Requested Action is not known by receiver NOT_IMPLEMENTED = 'NotImplemented', // Requested Action is recognized but not supported by the receiver @@ -17,6 +19,8 @@ export enum ErrorType { PROPERTY_CONSTRAINT_VIOLATION = 'PropertyConstraintViolation', // Payload for Action is incomplete PROTOCOL_ERROR = 'ProtocolError', + // Content of the call is not a valid RPC Request (OCPP 2.0.1 §4.3 Table 8) + RPC_FRAMEWORK_ERROR = 'RpcFrameworkError', // During the processing of Action a security issue occurred preventing receiver from completing the Action successfully SECURITY_ERROR = 'SecurityError', // eslint-disable-next-line @cspell/spellchecker diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 908d93dd..9a9e55a6 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -402,15 +402,32 @@ export const insertAt = (str: string, subStr: string, pos: number): string => `${str.slice(0, pos)}${subStr}${str.slice(pos)}` /** - * Computes the retry delay in milliseconds using an exponential backoff algorithm. - * @param retryNumber - the number of retries that have already been attempted - * @param delayFactor - the base delay factor in milliseconds + * Generalized exponential back-off: baseDelayMs × 2^min(retryNumber, maxRetries) + jitter. + * @param options - back-off configuration + * @param options.baseDelayMs - base delay in milliseconds + * @param options.retryNumber - current retry attempt (0-based) + * @param options.maxRetries - stop doubling after this many retries (default: unlimited) + * @param options.jitterMs - maximum fixed random jitter in milliseconds (default: 0) + * @param options.jitterPercent - proportional jitter as fraction of computed delay, e.g. 0.2 = 20% (default: 0) * @returns delay in milliseconds */ -export const exponentialDelay = (retryNumber = 0, delayFactor = 100): number => { - const delay = 2 ** retryNumber * delayFactor - const randomSum = delay * 0.2 * secureRandom() // 0-20% of the delay - return delay + randomSum +export const computeExponentialBackOffDelay = (options: { + baseDelayMs: number + jitterMs?: number + jitterPercent?: number + maxRetries?: number + retryNumber: number +}): number => { + const { baseDelayMs, jitterMs, jitterPercent, maxRetries, retryNumber } = options + const effectiveRetry = maxRetries != null ? Math.min(retryNumber, maxRetries) : retryNumber + const delay = baseDelayMs * 2 ** effectiveRetry + let jitter = 0 + if (jitterPercent != null && jitterPercent > 0) { + jitter = delay * jitterPercent * secureRandom() + } else if (jitterMs != null && jitterMs > 0) { + jitter = secureRandom() * jitterMs + } + return delay + jitter } /** diff --git a/src/utils/index.ts b/src/utils/index.ts index f1c900aa..e6050e88 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -33,12 +33,12 @@ export { average, max, median, min, percentile, std } from './StatisticUtils.js' export { clampToSafeTimerValue, clone, + computeExponentialBackOffDelay, convertToBoolean, convertToDate, convertToFloat, convertToInt, convertToIntOrNaN, - exponentialDelay, extractTimeSeriesValues, formatDurationMilliSeconds, formatDurationSeconds, diff --git a/tests/charging-station/ocpp/1.6/OCPP16ServiceUtils.test.ts b/tests/charging-station/ocpp/1.6/OCPP16ServiceUtils.test.ts index 720a7c9e..ab84ac37 100644 --- a/tests/charging-station/ocpp/1.6/OCPP16ServiceUtils.test.ts +++ b/tests/charging-station/ocpp/1.6/OCPP16ServiceUtils.test.ts @@ -1,21 +1,28 @@ /** * @file Tests for OCPP16ServiceUtils pure utility functions * @module OCPP 1.6 — §4.7 MeterValues (meter value building), §9.3 SetChargingProfile - * (charging profile management), §3 ChargePoint status (connector status transitions) + * (charging profile management), §3 ChargePoint status (connector status transitions), + * §9.4 ClearChargingProfile (Errata 3.25 AND logic), authorization cache updates * @description Verifies pure static methods on OCPP16ServiceUtils: meter value building, - * charging profile management, feature profile checking, and command support checks. + * charging profile management, feature profile checking, command support checks, + * and authorization cache update behavior. */ import assert from 'node:assert/strict' import { afterEach, describe, it } from 'node:test' import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js' +import { + AuthorizationStatus, + OCPPAuthServiceFactory, +} from '../../../../src/charging-station/ocpp/auth/index.js' import { isIncomingRequestCommandSupported, isRequestCommandSupported, } from '../../../../src/charging-station/ocpp/OCPPServiceUtils.js' import { ChargePointErrorCode, + OCPP16AuthorizationStatus, OCPP16ChargePointStatus, type OCPP16ChargingProfile, OCPP16ChargingProfileKindType, @@ -23,6 +30,7 @@ import { OCPP16ChargingRateUnitType, type OCPP16ChargingSchedule, type OCPP16ClearChargingProfileRequest, + type OCPP16IdTagInfo, OCPP16IncomingRequestCommand, type OCPP16MeterValue, OCPP16MeterValueContext, @@ -35,7 +43,7 @@ import { OCPPVersion, } from '../../../../src/types/index.js' import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' -import { createMockChargingStation } from '../../helpers/StationHelpers.js' +import { createMockChargingStation } from '../../ChargingStationTestUtils.js' import { createCommandsSupport, createMeterValuesTemplate } from './OCPP16TestUtils.js' await describe('OCPP16ServiceUtils — pure functions', async () => { @@ -390,6 +398,93 @@ await describe('OCPP16ServiceUtils — pure functions', async () => { assert.strictEqual(result, false) assert.strictEqual(profiles.length, 1) }) + + await it('should clear profile matching all specified criteria (AND logic)', () => { + // Arrange + const { station } = createMockChargingStation({ ocppVersion: OCPPVersion.VERSION_16 }) + const profiles = [ + makeProfile(1, OCPP16ChargingProfilePurposeType.TX_DEFAULT_PROFILE, 0), + makeProfile(2, OCPP16ChargingProfilePurposeType.TX_PROFILE, 1), + makeProfile(3, OCPP16ChargingProfilePurposeType.TX_PROFILE, 0), + ] + const payload: OCPP16ClearChargingProfileRequest = { + chargingProfilePurpose: OCPP16ChargingProfilePurposeType.TX_PROFILE, + id: 2, + stackLevel: 1, + } + + // Act + const result = OCPP16ServiceUtils.clearChargingProfiles(station, payload, profiles) + + // Assert — only profile 2 matches all three criteria + assert.strictEqual(result, true) + assert.strictEqual(profiles.length, 2) + assert.strictEqual(profiles[0].chargingProfileId, 1) + assert.strictEqual(profiles[1].chargingProfileId, 3) + }) + + await it('should treat null fields as wildcards', () => { + // Arrange + const { station } = createMockChargingStation({ ocppVersion: OCPPVersion.VERSION_16 }) + const profiles = [ + makeProfile(1, OCPP16ChargingProfilePurposeType.TX_DEFAULT_PROFILE, 0), + makeProfile(2, OCPP16ChargingProfilePurposeType.TX_PROFILE, 1), + makeProfile(3, OCPP16ChargingProfilePurposeType.TX_PROFILE, 5), + ] + const payload: OCPP16ClearChargingProfileRequest = { + chargingProfilePurpose: OCPP16ChargingProfilePurposeType.TX_PROFILE, + } + + // Act + const result = OCPP16ServiceUtils.clearChargingProfiles(station, payload, profiles) + + // Assert — id and stackLevel are null (wildcards), so all TxProfile profiles cleared + assert.strictEqual(result, true) + assert.strictEqual(profiles.length, 1) + assert.strictEqual(profiles[0].chargingProfileId, 1) + }) + + await it('should not clear profile when only some criteria match', () => { + // Arrange + const { station } = createMockChargingStation({ ocppVersion: OCPPVersion.VERSION_16 }) + const profiles = [ + makeProfile(1, OCPP16ChargingProfilePurposeType.TX_DEFAULT_PROFILE, 0), + makeProfile(2, OCPP16ChargingProfilePurposeType.TX_PROFILE, 1), + ] + const payload: OCPP16ClearChargingProfileRequest = { + chargingProfilePurpose: OCPP16ChargingProfilePurposeType.TX_PROFILE, + id: 1, + } + + // Act + const result = OCPP16ServiceUtils.clearChargingProfiles(station, payload, profiles) + + // Assert — id=1 matches P1 but purpose doesn't; purpose matches P2 but id doesn't + assert.strictEqual(result, false) + assert.strictEqual(profiles.length, 2) + }) + + await it('should clear multiple matching profiles', () => { + // Arrange + const { station } = createMockChargingStation({ ocppVersion: OCPPVersion.VERSION_16 }) + const profiles = [ + makeProfile(1, OCPP16ChargingProfilePurposeType.TX_DEFAULT_PROFILE, 0), + makeProfile(2, OCPP16ChargingProfilePurposeType.TX_DEFAULT_PROFILE, 0), + makeProfile(3, OCPP16ChargingProfilePurposeType.TX_PROFILE, 1), + ] + const payload: OCPP16ClearChargingProfileRequest = { + chargingProfilePurpose: OCPP16ChargingProfilePurposeType.TX_DEFAULT_PROFILE, + stackLevel: 0, + } + + // Act + const result = OCPP16ServiceUtils.clearChargingProfiles(station, payload, profiles) + + // Assert — profiles 1 and 2 both match purpose + stackLevel + assert.strictEqual(result, true) + assert.strictEqual(profiles.length, 1) + assert.strictEqual(profiles[0].chargingProfileId, 3) + }) }) // ─── composeChargingSchedules ────────────────────────────────────────── @@ -756,4 +851,142 @@ await describe('OCPP16ServiceUtils — pure functions', async () => { assert.strictEqual(result, false) }) }) + + // ─── updateAuthorizationCache ────────────────────────────────────────── + + await describe('updateAuthorizationCache', async () => { + const TEST_ID_TAG = 'TEST_RFID_001' + + afterEach(() => { + OCPPAuthServiceFactory.clearAllInstances() + }) + + await it('should update auth cache with Accepted status', () => { + // Arrange + const { station } = createMockChargingStation({ + ocppVersion: OCPPVersion.VERSION_16, + stationInfo: { + chargingStationId: 'CS_CACHE_TEST_01', + ocppVersion: OCPPVersion.VERSION_16, + }, + }) + const idTagInfo: OCPP16IdTagInfo = { + status: OCPP16AuthorizationStatus.ACCEPTED, + } + + // Act + OCPP16ServiceUtils.updateAuthorizationCache(station, TEST_ID_TAG, idTagInfo) + + // Assert + const authService = OCPPAuthServiceFactory.getInstance(station) + const authCache = authService.getAuthCache() + assert.ok(authCache != null) + const cached = authCache.get(TEST_ID_TAG) + assert.ok(cached != null) + assert.strictEqual(cached.status, AuthorizationStatus.ACCEPTED) + }) + + await it('should update auth cache with rejected status', () => { + // Arrange + const { station } = createMockChargingStation({ + ocppVersion: OCPPVersion.VERSION_16, + stationInfo: { + chargingStationId: 'CS_CACHE_TEST_02', + ocppVersion: OCPPVersion.VERSION_16, + }, + }) + const idTagInfo: OCPP16IdTagInfo = { + status: OCPP16AuthorizationStatus.BLOCKED, + } + + // Act + OCPP16ServiceUtils.updateAuthorizationCache(station, TEST_ID_TAG, idTagInfo) + + // Assert + const authService = OCPPAuthServiceFactory.getInstance(station) + const authCache = authService.getAuthCache() + assert.ok(authCache != null) + const cached = authCache.get(TEST_ID_TAG) + assert.ok(cached != null) + assert.strictEqual(cached.status, AuthorizationStatus.BLOCKED) + }) + + await it('should set TTL from expiryDate when in future', () => { + // Arrange + const { station } = createMockChargingStation({ + ocppVersion: OCPPVersion.VERSION_16, + stationInfo: { + chargingStationId: 'CS_CACHE_TEST_03', + ocppVersion: OCPPVersion.VERSION_16, + }, + }) + const futureDate = new Date(Date.now() + 600_000) + const idTagInfo: OCPP16IdTagInfo = { + expiryDate: futureDate, + status: OCPP16AuthorizationStatus.ACCEPTED, + } + + // Act + OCPP16ServiceUtils.updateAuthorizationCache(station, TEST_ID_TAG, idTagInfo) + + // Assert + const authService = OCPPAuthServiceFactory.getInstance(station) + const authCache = authService.getAuthCache() + assert.ok(authCache != null) + const cached = authCache.get(TEST_ID_TAG) + assert.ok(cached != null, 'Cache entry should exist with future TTL') + assert.strictEqual(cached.status, AuthorizationStatus.ACCEPTED) + }) + + await it('should skip caching when expiryDate is in the past', () => { + // Arrange + const { station } = createMockChargingStation({ + ocppVersion: OCPPVersion.VERSION_16, + stationInfo: { + chargingStationId: 'CS_CACHE_TEST_04', + ocppVersion: OCPPVersion.VERSION_16, + }, + }) + const pastDate = new Date(Date.now() - 60_000) + const idTagInfo: OCPP16IdTagInfo = { + expiryDate: pastDate, + status: OCPP16AuthorizationStatus.ACCEPTED, + } + + // Act + OCPP16ServiceUtils.updateAuthorizationCache(station, TEST_ID_TAG, idTagInfo) + + // Assert + const authService = OCPPAuthServiceFactory.getInstance(station) + const authCache = authService.getAuthCache() + assert.ok(authCache != null) + const cached = authCache.get(TEST_ID_TAG) + assert.strictEqual(cached, undefined, 'Expired entry must not be cached') + }) + + await it('should cache without TTL when no expiryDate', () => { + // Arrange + const { station } = createMockChargingStation({ + ocppVersion: OCPPVersion.VERSION_16, + stationInfo: { + chargingStationId: 'CS_CACHE_TEST_05', + ocppVersion: OCPPVersion.VERSION_16, + }, + }) + const idTagInfo: OCPP16IdTagInfo = { + status: OCPP16AuthorizationStatus.ACCEPTED, + } + + // Act + OCPP16ServiceUtils.updateAuthorizationCache(station, TEST_ID_TAG, idTagInfo) + + // Assert + const authService = OCPPAuthServiceFactory.getInstance(station) + const authCache = authService.getAuthCache() + assert.ok(authCache != null) + const cached = authCache.get(TEST_ID_TAG) + assert.ok(cached != null, 'Cache entry should exist without TTL') + assert.strictEqual(cached.status, AuthorizationStatus.ACCEPTED) + }) + }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.test.ts b/tests/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.test.ts new file mode 100644 index 00000000..b06ce775 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.test.ts @@ -0,0 +1,339 @@ +/** + * @file Tests for OCPP20CertSigningRetryManager + * @description Verifies certificate signing retry lifecycle per OCPP 2.0.1 A02.FR.17-19 + */ + +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it, mock } from 'node:test' + +import type { ChargingStation } from '../../../../src/charging-station/index.js' + +import { buildConfigKey } from '../../../../src/charging-station/ConfigurationKeyUtils.js' +import { OCPP20CertSigningRetryManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.js' +import { OCPP20VariableManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js' +import { + GenericStatus, + OCPP20ComponentName, + OCPP20OptionalVariableName, + OCPP20RequestCommand, + OCPPVersion, +} from '../../../../src/types/index.js' +import { Constants } from '../../../../src/utils/index.js' +import { + flushMicrotasks, + standardCleanup, + withMockTimers, +} from '../../../helpers/TestLifecycleHelpers.js' +import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js' +import { createMockChargingStation } from '../../ChargingStationTestUtils.js' +import { upsertConfigurationKey } from './OCPP20TestUtils.js' + +await describe('OCPP20CertSigningRetryManager', async () => { + let station: ChargingStation + let manager: OCPP20CertSigningRetryManager + let requestHandlerMock: ReturnType Promise>> + + beforeEach(() => { + requestHandlerMock = mock.fn<(...args: unknown[]) => Promise>(() => + Promise.resolve({ + status: GenericStatus.Accepted, + }) + ) + + const { station: newStation } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 1, + evseConfiguration: { evsesCount: 1 }, + heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL, + ocppRequestService: { + requestHandler: requestHandlerMock, + }, + stationInfo: { + ocppVersion: OCPPVersion.VERSION_201, + }, + websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL, + }) + station = newStation + + upsertConfigurationKey( + station, + buildConfigKey( + OCPP20ComponentName.SecurityCtrlr, + OCPP20OptionalVariableName.CertSigningWaitMinimum + ), + '5' + ) + upsertConfigurationKey( + station, + buildConfigKey( + OCPP20ComponentName.SecurityCtrlr, + OCPP20OptionalVariableName.CertSigningRepeatTimes + ), + '3' + ) + + manager = new OCPP20CertSigningRetryManager(station) + }) + + afterEach(() => { + manager.cancelRetryTimer() + standardCleanup() + OCPP20VariableManager.getInstance().resetRuntimeOverrides() + OCPP20VariableManager.getInstance().invalidateMappingsCache() + }) + + await describe('startRetryTimer', async () => { + await it('should schedule first retry after CertSigningWaitMinimum seconds', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + // Arrange + const WAIT_MINIMUM_MS = 5000 + + // Act + manager.startRetryTimer() + + // Assert + assert.strictEqual(requestHandlerMock.mock.callCount(), 0) + // retryNumber=0 → delay = baseDelay * 2^0 = 5000ms + t.mock.timers.tick(WAIT_MINIMUM_MS) + await flushMicrotasks() + assert.strictEqual(requestHandlerMock.mock.callCount(), 1) + }) + }) + + await it('should not start when CertSigningWaitMinimum is not configured', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + upsertConfigurationKey( + station, + buildConfigKey( + OCPP20ComponentName.SecurityCtrlr, + OCPP20OptionalVariableName.CertSigningWaitMinimum + ), + '0' + ) + + // Act + manager.startRetryTimer() + t.mock.timers.tick(60000) + await flushMicrotasks() + + // Assert + assert.strictEqual(requestHandlerMock.mock.callCount(), 0) + }) + }) + + await it('should not retry when CertSigningRepeatTimes is zero', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + upsertConfigurationKey( + station, + buildConfigKey( + OCPP20ComponentName.SecurityCtrlr, + OCPP20OptionalVariableName.CertSigningRepeatTimes + ), + '0' + ) + + manager.startRetryTimer() + t.mock.timers.tick(60000) + await flushMicrotasks() + + assert.strictEqual(requestHandlerMock.mock.callCount(), 0) + }) + }) + + await it('should cancel existing timer before starting new one', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + // Arrange + manager.startRetryTimer() + + // Act + manager.startRetryTimer() + t.mock.timers.tick(5000) + await flushMicrotasks() + + // Assert + assert.strictEqual(requestHandlerMock.mock.callCount(), 1) + }) + }) + }) + + await describe('cancelRetryTimer', async () => { + await it('should clear pending timer', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + // Arrange + manager.startRetryTimer() + + // Act + manager.cancelRetryTimer() + t.mock.timers.tick(60000) + await flushMicrotasks() + + // Assert + assert.strictEqual(requestHandlerMock.mock.callCount(), 0) + }) + }) + + await it('should set retryAborted flag to prevent in-flight callbacks', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + // Arrange + let resolveHandler: ((value: { status: GenericStatus }) => void) | undefined + requestHandlerMock.mock.mockImplementation( + () => + new Promise<{ status: GenericStatus }>(resolve => { + resolveHandler = resolve + }) + ) + manager.startRetryTimer() + t.mock.timers.tick(5000) + await flushMicrotasks() + assert.strictEqual(requestHandlerMock.mock.callCount(), 1) + + // Act + manager.cancelRetryTimer() + assert.notStrictEqual(resolveHandler, undefined) + resolveHandler?.({ status: GenericStatus.Accepted }) + await flushMicrotasks() + + // Assert — no further retry despite Accepted response + t.mock.timers.tick(60000) + await flushMicrotasks() + assert.strictEqual(requestHandlerMock.mock.callCount(), 1) + }) + }) + }) + + await describe('retry lifecycle', async () => { + await it('A02.FR.17 - should send SignCertificateRequest on retry', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + manager.startRetryTimer() + t.mock.timers.tick(5000) + await flushMicrotasks() + + assert.strictEqual(requestHandlerMock.mock.callCount(), 1) + const call = requestHandlerMock.mock.calls[0] + assert.strictEqual(call.arguments[1], OCPP20RequestCommand.SIGN_CERTIFICATE) + }) + }) + + await it('A02.FR.18 - should double backoff on each retry', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + // Exponential backoff: delay = 5000 * 2^retryNumber + // Retry 0: 5000ms, Retry 1: 10000ms, Retry 2: 20000ms + manager.startRetryTimer() + + t.mock.timers.tick(5000) + await flushMicrotasks() + assert.strictEqual(requestHandlerMock.mock.callCount(), 1) + + t.mock.timers.tick(10000) + await flushMicrotasks() + assert.strictEqual(requestHandlerMock.mock.callCount(), 2) + + t.mock.timers.tick(20000) + await flushMicrotasks() + assert.strictEqual(requestHandlerMock.mock.callCount(), 3) + }) + }) + + await it('A02.FR.19 - should stop after CertSigningRepeatTimes retries', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + // Arrange + upsertConfigurationKey( + station, + buildConfigKey( + OCPP20ComponentName.SecurityCtrlr, + OCPP20OptionalVariableName.CertSigningRepeatTimes + ), + '2' + ) + OCPP20VariableManager.getInstance().invalidateMappingsCache() + + // Act + manager.startRetryTimer() + t.mock.timers.tick(5000) + await flushMicrotasks() + assert.strictEqual(requestHandlerMock.mock.callCount(), 1) + + t.mock.timers.tick(10000) + await flushMicrotasks() + assert.strictEqual(requestHandlerMock.mock.callCount(), 2) + + // Assert — no third retry, max reached + t.mock.timers.tick(60000) + await flushMicrotasks() + assert.strictEqual(requestHandlerMock.mock.callCount(), 2) + }) + }) + + await it('should propagate certificateType to retry requests', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + manager.startRetryTimer('V2GCertificate') + t.mock.timers.tick(5000) + await flushMicrotasks() + + assert.strictEqual(requestHandlerMock.mock.callCount(), 1) + const call = requestHandlerMock.mock.calls[0] + assert.strictEqual(call.arguments[1], OCPP20RequestCommand.SIGN_CERTIFICATE) + const payload = call.arguments[2] as Record + assert.strictEqual(payload.certificateType, 'V2GCertificate') + }) + }) + + await it('should send empty payload when certificateType is not specified', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + manager.startRetryTimer() + t.mock.timers.tick(5000) + await flushMicrotasks() + + assert.strictEqual(requestHandlerMock.mock.callCount(), 1) + const call = requestHandlerMock.mock.calls[0] + const payload = call.arguments[2] as Record + assert.deepStrictEqual(payload, {}) + }) + }) + + await it('should stop retries when CSMS rejects SignCertificate', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + // Arrange + requestHandlerMock.mock.mockImplementation(async () => + Promise.resolve({ status: GenericStatus.Rejected }) + ) + + // Act + manager.startRetryTimer() + t.mock.timers.tick(5000) + await flushMicrotasks() + + // Assert + assert.strictEqual(requestHandlerMock.mock.callCount(), 1) + t.mock.timers.tick(60000) + await flushMicrotasks() + assert.strictEqual(requestHandlerMock.mock.callCount(), 1) + }) + }) + + await it('should reschedule retry on request failure', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + // Arrange + let callNumber = 0 + requestHandlerMock.mock.mockImplementation(async () => { + callNumber++ + if (callNumber === 1) { + return Promise.reject(new Error('Network error')) + } + return Promise.resolve({ status: GenericStatus.Accepted }) + }) + + // Act + manager.startRetryTimer() + t.mock.timers.tick(5000) + await flushMicrotasks() + assert.strictEqual(requestHandlerMock.mock.callCount(), 1) + + // Assert — error handler reschedules with exponential backoff + t.mock.timers.tick(10000) + await flushMicrotasks() + assert.strictEqual(requestHandlerMock.mock.callCount(), 2) + }) + }) + }) +}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20ResponseService-CacheUpdate.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ResponseService-CacheUpdate.test.ts index 8df8cc9c..b4aea424 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20ResponseService-CacheUpdate.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20ResponseService-CacheUpdate.test.ts @@ -1,7 +1,7 @@ /** * @file Tests for OCPP20ResponseService cache update on TransactionEventResponse * @description Unit tests for auth cache auto-update from TransactionEventResponse idTokenInfo - * per OCPP 2.0.1 C10.FR.01/04/05, C12.FR.06, C02.FR.03, C03.FR.02 + * per OCPP 2.0.1 C10.FR.01/05, C12.FR.06, C02.FR.03, C03.FR.02 */ import assert from 'node:assert/strict' diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-AuthCache.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-AuthCache.test.ts new file mode 100644 index 00000000..9c441c21 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-AuthCache.test.ts @@ -0,0 +1,123 @@ +/** + * @file Tests for OCPP20ServiceUtils.updateAuthorizationCache + * @description Verifies the static helper that delegates auth cache updates to OCPPAuthService, + * covering the C10.FR.04 AuthorizeResponse path and graceful error 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 type { LocalAuthStrategy } from '../../../../src/charging-station/ocpp/auth/index.js' +import type { AuthCache } from '../../../../src/charging-station/ocpp/auth/interfaces/OCPPAuthService.js' + +import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js' +import { + AuthorizationStatus, + OCPPAuthServiceFactory, + OCPPAuthServiceImpl, +} from '../../../../src/charging-station/ocpp/auth/index.js' +import { + OCPP20AuthorizationStatusEnumType, + OCPP20IdTokenEnumType, + type OCPP20IdTokenType, + OCPPVersion, +} from '../../../../src/types/index.js' +import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' +import { createMockChargingStation } from '../../ChargingStationTestUtils.js' + +const TEST_STATION_ID = 'CS_AUTH_CACHE_UTILS_TEST' +const TEST_TOKEN_VALUE = 'RFID_AUTH_CACHE_001' + +await describe('OCPP20ServiceUtils.updateAuthorizationCache', async () => { + let station: ChargingStation + let authService: OCPPAuthServiceImpl + let authCache: AuthCache + + beforeEach(() => { + const { station: mockStation } = createMockChargingStation({ + baseName: TEST_STATION_ID, + connectorsCount: 1, + stationInfo: { + chargingStationId: TEST_STATION_ID, + ocppVersion: OCPPVersion.VERSION_201, + }, + }) + station = mockStation + + authService = new OCPPAuthServiceImpl(station) + authService.initialize() + OCPPAuthServiceFactory.setInstanceForTesting(TEST_STATION_ID, authService) + + const localStrategy = authService.getStrategy('local') as LocalAuthStrategy | undefined + const cache = localStrategy?.getAuthCache() + assert.ok(cache != null, 'Auth cache must be available after initialization') + authCache = cache + }) + + afterEach(() => { + OCPPAuthServiceFactory.clearAllInstances() + standardCleanup() + }) + + await it('C10.FR.04 - should update cache on AuthorizeResponse via updateAuthorizationCache', () => { + // Arrange + const idToken: OCPP20IdTokenType = { + idToken: TEST_TOKEN_VALUE, + type: OCPP20IdTokenEnumType.ISO14443, + } + const idTokenInfo = { + status: OCPP20AuthorizationStatusEnumType.Accepted, + } + + // Act + OCPP20ServiceUtils.updateAuthorizationCache(station, idToken, idTokenInfo) + + // Assert + const cached = authCache.get(TEST_TOKEN_VALUE) + assert.ok(cached != null, 'AuthorizeResponse should update the cache') + assert.strictEqual(cached.status, AuthorizationStatus.ACCEPTED) + }) + + await it('C10.FR.01 - should cache non-Accepted status through utility helper', () => { + const idToken: OCPP20IdTokenType = { + idToken: 'BLOCKED_TOKEN_001', + type: OCPP20IdTokenEnumType.ISO14443, + } + const idTokenInfo = { + status: OCPP20AuthorizationStatusEnumType.Blocked, + } + + OCPP20ServiceUtils.updateAuthorizationCache(station, idToken, idTokenInfo) + + const cached = authCache.get('BLOCKED_TOKEN_001') + assert.ok(cached != null, 'Blocked status should be cached per C10.FR.01') + assert.strictEqual(cached.status, AuthorizationStatus.BLOCKED) + }) + + await it('should handle auth service initialization failure gracefully', () => { + // Arrange + const { station: isolatedStation } = createMockChargingStation({ + baseName: 'CS_NO_AUTH_SERVICE', + connectorsCount: 1, + stationInfo: { + chargingStationId: 'CS_NO_AUTH_SERVICE', + ocppVersion: OCPPVersion.VERSION_201, + }, + }) + OCPPAuthServiceFactory.clearAllInstances() + + const idToken: OCPP20IdTokenType = { + idToken: TEST_TOKEN_VALUE, + type: OCPP20IdTokenEnumType.ISO14443, + } + const idTokenInfo = { + status: OCPP20AuthorizationStatusEnumType.Accepted, + } + + // Act + assert.doesNotThrow(() => { + OCPP20ServiceUtils.updateAuthorizationCache(isolatedStation, idToken, idTokenInfo) + }) + }) +}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-ReconnectDelay.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-ReconnectDelay.test.ts new file mode 100644 index 00000000..fd34f17f --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-ReconnectDelay.test.ts @@ -0,0 +1,151 @@ +/** + * @file Tests for OCPP20ServiceUtils.computeReconnectDelay + * @description Verifies OCPP 2.0.1 §8.1-§8.3 RetryBackOff reconnection delay computation, + * including default values, variable-configured values, and retry capping. + */ + +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it } from 'node:test' + +import type { ChargingStation } from '../../../../src/charging-station/index.js' + +import { buildConfigKey } from '../../../../src/charging-station/index.js' +import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js' +import { OCPP20VariableManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js' +import { + OCPP20ComponentName, + OCPP20OptionalVariableName, + OCPPVersion, +} from '../../../../src/types/index.js' +import { Constants } from '../../../../src/utils/index.js' +import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' +import { createMockChargingStation } from '../../ChargingStationTestUtils.js' +import { upsertConfigurationKey } from './OCPP20TestUtils.js' + +const TEST_STATION_ID = 'CS_RECONNECT_DELAY_TEST' + +const DEFAULT_WAIT_MINIMUM_S = 30 +const DEFAULT_RANDOM_RANGE_S = 10 +const DEFAULT_REPEAT_TIMES = 5 + +const MS_PER_SECOND = 1000 + +/** + * @param station - target station + * @param waitMinimum - RetryBackOffWaitMinimum in seconds + * @param randomRange - RetryBackOffRandomRange in seconds + * @param repeatTimes - RetryBackOffRepeatTimes count + */ +function setRetryBackOffVariables ( + station: ChargingStation, + waitMinimum: number, + randomRange: number, + repeatTimes: number +): void { + upsertConfigurationKey( + station, + buildConfigKey( + OCPP20ComponentName.OCPPCommCtrlr, + OCPP20OptionalVariableName.RetryBackOffWaitMinimum + ), + waitMinimum.toString() + ) + upsertConfigurationKey( + station, + buildConfigKey( + OCPP20ComponentName.OCPPCommCtrlr, + OCPP20OptionalVariableName.RetryBackOffRandomRange + ), + randomRange.toString() + ) + upsertConfigurationKey( + station, + buildConfigKey( + OCPP20ComponentName.OCPPCommCtrlr, + OCPP20OptionalVariableName.RetryBackOffRepeatTimes + ), + repeatTimes.toString() + ) +} + +await describe('OCPP20ServiceUtils.computeReconnectDelay', async () => { + let station: ChargingStation + + beforeEach(() => { + const { station: mockStation } = createMockChargingStation({ + baseName: TEST_STATION_ID, + connectorsCount: 1, + heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL, + stationInfo: { + chargingStationId: TEST_STATION_ID, + ocppVersion: OCPPVersion.VERSION_201, + }, + }) + station = mockStation + }) + + afterEach(() => { + OCPP20VariableManager.getInstance().resetRuntimeOverrides() + standardCleanup() + }) + + await it('should compute delay using RetryBackOff variables', () => { + // Arrange + const configuredWaitMinimum = 20 + const configuredRandomRange = 5 + const configuredRepeatTimes = 3 + setRetryBackOffVariables( + station, + configuredWaitMinimum, + configuredRandomRange, + configuredRepeatTimes + ) + const retryCount = 2 + + // Act + const delay = OCPP20ServiceUtils.computeReconnectDelay(station, retryCount) + + // Assert — delay = waitMinimum * 2^(retryCount-1) + jitter ∈ [0, randomRange) + const expectedBaseDelayMs = configuredWaitMinimum * MS_PER_SECOND * 2 ** (retryCount - 1) + const maxJitterMs = configuredRandomRange * MS_PER_SECOND + assert.ok(delay >= expectedBaseDelayMs, 'delay should be >= base') + assert.ok(delay < expectedBaseDelayMs + maxJitterMs, 'delay should be < base + jitter') + }) + + await it('should use default values when variables not configured', () => { + const retryCount = 1 + + // Act + const delay = OCPP20ServiceUtils.computeReconnectDelay(station, retryCount) + + // Assert — retryCount=1 → effectiveRetry=0 → baseDelay = 30s * 2^0 = 30000ms + const expectedBaseDelayMs = DEFAULT_WAIT_MINIMUM_S * MS_PER_SECOND + const maxJitterMs = DEFAULT_RANDOM_RANGE_S * MS_PER_SECOND + assert.ok(delay >= expectedBaseDelayMs, 'delay should be >= default base') + assert.ok(delay < expectedBaseDelayMs + maxJitterMs, 'delay should be < default base + jitter') + }) + + await it('should cap retry doubling at RetryBackOffRepeatTimes', () => { + const retryCountBeyondCap = 20 + const retryCountAtCap = DEFAULT_REPEAT_TIMES + 1 + + // Act + const delayBeyondCap = OCPP20ServiceUtils.computeReconnectDelay(station, retryCountBeyondCap) + const delayAtCap = OCPP20ServiceUtils.computeReconnectDelay(station, retryCountAtCap) + + // Assert — both capped: effectiveRetry = min(retryCount-1, repeatTimes) = 5 + // baseDelay = 30s * 2^5 = 960000ms + const cappedBaseDelayMs = DEFAULT_WAIT_MINIMUM_S * MS_PER_SECOND * 2 ** DEFAULT_REPEAT_TIMES + const maxJitterMs = DEFAULT_RANDOM_RANGE_S * MS_PER_SECOND + assert.ok(delayBeyondCap >= cappedBaseDelayMs, 'beyond-cap delay should be >= capped base') + assert.ok( + delayBeyondCap < cappedBaseDelayMs + maxJitterMs, + 'beyond-cap delay should be < capped base + jitter' + ) + assert.ok(delayAtCap >= cappedBaseDelayMs, 'at-cap delay should be >= capped base') + assert.ok( + delayAtCap < cappedBaseDelayMs + maxJitterMs, + 'at-cap delay should be < capped base + jitter' + ) + }) +}) diff --git a/tests/utils/Utils.test.ts b/tests/utils/Utils.test.ts index 0930d08d..84d3c423 100644 --- a/tests/utils/Utils.test.ts +++ b/tests/utils/Utils.test.ts @@ -19,12 +19,12 @@ import { Constants } from '../../src/utils/index.js' import { clampToSafeTimerValue, clone, + computeExponentialBackOffDelay, convertToBoolean, convertToDate, convertToFloat, convertToInt, convertToIntOrNaN, - exponentialDelay, extractTimeSeriesValues, formatDurationMilliSeconds, formatDurationSeconds, @@ -556,141 +556,156 @@ await describe('Utils', async () => { assert.strictEqual(clampToSafeTimerValue(-1000), 0) }) - // ------------------------------------------------------------------------- - // Exponential Backoff Algorithm Tests (WebSocket Reconnection) - // ------------------------------------------------------------------------- + await describe('computeExponentialBackOffDelay', async () => { + await it('should calculate exponential delay with default-like parameters', () => { + const baseDelayMs = 100 - await it('should calculate exponential delay with default parameters', () => { - // Formula: delay = 2^retryNumber * delayFactor + (0-20% random jitter) - // With default delayFactor = 100ms + // retryNumber = 0: 2^0 * 100 = 100ms base, no jitter + const delay0 = computeExponentialBackOffDelay({ baseDelayMs, retryNumber: 0 }) + assert.strictEqual(delay0, 100) - // retryNumber = 0: 2^0 * 100 = 100ms base - const delay0 = exponentialDelay(0) - assert.strictEqual(delay0 >= 100, true) - assert.strictEqual(delay0 <= 120, true) // 100 + 20% max jitter + // retryNumber = 1: 2^1 * 100 = 200ms base + const delay1 = computeExponentialBackOffDelay({ baseDelayMs, retryNumber: 1 }) + assert.strictEqual(delay1, 200) - // retryNumber = 1: 2^1 * 100 = 200ms base - const delay1 = exponentialDelay(1) - assert.strictEqual(delay1 >= 200, true) - assert.strictEqual(delay1 <= 240, true) // 200 + 20% max jitter + // retryNumber = 2: 2^2 * 100 = 400ms base + const delay2 = computeExponentialBackOffDelay({ baseDelayMs, retryNumber: 2 }) + assert.strictEqual(delay2, 400) - // retryNumber = 2: 2^2 * 100 = 400ms base - const delay2 = exponentialDelay(2) - assert.strictEqual(delay2 >= 400, true) - assert.strictEqual(delay2 <= 480, true) // 400 + 20% max jitter + // retryNumber = 3: 2^3 * 100 = 800ms base + const delay3 = computeExponentialBackOffDelay({ baseDelayMs, retryNumber: 3 }) + assert.strictEqual(delay3, 800) + }) - // retryNumber = 3: 2^3 * 100 = 800ms base - const delay3 = exponentialDelay(3) - assert.strictEqual(delay3 >= 800, true) - assert.strictEqual(delay3 <= 960, true) // 800 + 20% max jitter - }) + await it('should calculate exponential delay with custom base delay', () => { + const delay0 = computeExponentialBackOffDelay({ baseDelayMs: 50, retryNumber: 0 }) + assert.strictEqual(delay0, 50) - await it('should calculate exponential delay with custom delay factor', () => { - // Custom delayFactor = 50ms - const delay0 = exponentialDelay(0, 50) - assert.strictEqual(delay0 >= 50, true) - assert.strictEqual(delay0 <= 60, true) // 50 + 20% max jitter + const delay1 = computeExponentialBackOffDelay({ baseDelayMs: 50, retryNumber: 1 }) + assert.strictEqual(delay1, 100) - const delay1 = exponentialDelay(1, 50) - assert.strictEqual(delay1 >= 100, true) - assert.strictEqual(delay1 <= 120, true) + const delay2 = computeExponentialBackOffDelay({ baseDelayMs: 200, retryNumber: 2 }) + assert.strictEqual(delay2, 800) // 2^2 * 200 = 800 + }) - // Custom delayFactor = 200ms - const delay2 = exponentialDelay(2, 200) - assert.strictEqual(delay2 >= 800, true) // 2^2 * 200 = 800 - assert.strictEqual(delay2 <= 960, true) - }) + await it('should follow 2^n exponential growth pattern', () => { + const baseDelayMs = 100 + const delays: number[] = [] + for (let retry = 0; retry <= 5; retry++) { + delays.push(computeExponentialBackOffDelay({ baseDelayMs, retryNumber: retry })) + } - await it('should follow 2^n exponential growth pattern', () => { - // Verify that delays follow 2^n exponential growth pattern - const delayFactor = 100 + // Each delay should be exactly double the previous (no jitter) + for (let i = 1; i < delays.length; i++) { + assert.strictEqual(delays[i] / delays[i - 1], 2) + } + }) - // Collect base delays (without jitter consideration) - const delays: number[] = [] - for (let retry = 0; retry <= 5; retry++) { - delays.push(exponentialDelay(retry, delayFactor)) - } + await it('should include random jitter when jitterMs is specified', () => { + const delays = new Set() + const baseDelayMs = 100 + + for (let i = 0; i < 10; i++) { + delays.add( + Math.round( + computeExponentialBackOffDelay({ + baseDelayMs, + jitterMs: 50, + retryNumber: 3, + }) + ) + ) + } - // Each delay should be approximately double the previous (accounting for jitter) - // delay[n+1] / delay[n] should be close to 2 (between 1.5 and 2.5 with jitter) - for (let i = 1; i < delays.length; i++) { - const ratio = delays[i] / delays[i - 1] - // Allow for jitter variance - ratio should be roughly 2x - assert.strictEqual(ratio > 1.5, true) - assert.strictEqual(ratio < 2.5, true) - } - }) + // With jitter, we expect variation + assert.strictEqual(delays.size > 1, true) + }) - await it('should include random jitter in exponential delay', () => { - // Run multiple times to verify jitter produces different values - const delays = new Set() - const retryNumber = 3 - const delayFactor = 100 + await it('should keep jitter within specified range', () => { + const retryNumber = 4 + const baseDelayMs = 100 + const jitterMs = 200 + const expectedBase = Math.pow(2, retryNumber) * baseDelayMs // 1600ms - // Collect 10 samples - with cryptographically secure random, - // we should get variation (not all identical) - for (let i = 0; i < 10; i++) { - delays.add(Math.round(exponentialDelay(retryNumber, delayFactor))) - } + for (let i = 0; i < 20; i++) { + const delay = computeExponentialBackOffDelay({ baseDelayMs, jitterMs, retryNumber }) + const jitter = delay - expectedBase - // With jitter, we expect at least some variation - // (unlikely to get 10 identical values with secure random) - assert.strictEqual(delays.size > 1, true) - }) + assert.strictEqual(jitter >= 0, true) + assert.strictEqual(jitter <= jitterMs, true) + } + }) - await it('should keep jitter within 0-20% range of base delay', () => { - // For a given retry, jitter should add 0-20% of base delay - const retryNumber = 4 - const delayFactor = 100 - const baseDelay = Math.pow(2, retryNumber) * delayFactor // 1600ms + await it('should apply proportional jitter with jitterPercent', () => { + const retryNumber = 4 + const baseDelayMs = 100 + const jitterPercent = 0.2 + const expectedBase = Math.pow(2, retryNumber) * baseDelayMs // 1600ms + const maxJitter = expectedBase * jitterPercent // 320ms - // Run multiple samples to verify jitter range - for (let i = 0; i < 20; i++) { - const delay = exponentialDelay(retryNumber, delayFactor) - const jitter = delay - baseDelay + for (let i = 0; i < 20; i++) { + const delay = computeExponentialBackOffDelay({ baseDelayMs, jitterPercent, retryNumber }) + const jitter = delay - expectedBase - // Jitter should be non-negative and at most 20% of base delay - assert.strictEqual(jitter >= 0, true) - assert.strictEqual(jitter <= baseDelay * 0.2, true) - } - }) + assert.strictEqual(jitter >= 0, true) + assert.strictEqual(jitter <= maxJitter, true) + } + }) + + await it('should prioritize jitterPercent over jitterMs when both provided', () => { + const retryNumber = 3 + const baseDelayMs = 100 + const expectedBase = Math.pow(2, retryNumber) * baseDelayMs // 800ms + + for (let i = 0; i < 20; i++) { + const delay = computeExponentialBackOffDelay({ + baseDelayMs, + jitterMs: 5000, + jitterPercent: 0.1, + retryNumber, + }) + assert.strictEqual(delay >= expectedBase, true) + assert.strictEqual(delay <= expectedBase + expectedBase * 0.1, true) + } + }) - await it('should handle edge cases (default retry, large retry, small factor)', () => { - // Default retryNumber (0) - const defaultRetry = exponentialDelay() - assert.strictEqual(defaultRetry >= 100, true) // 2^0 * 100 - assert.strictEqual(defaultRetry <= 120, true) + await it('should handle edge cases (zero retry, large retry, small base)', () => { + const defaultRetry = computeExponentialBackOffDelay({ baseDelayMs: 100, retryNumber: 0 }) + assert.strictEqual(defaultRetry, 100) // 2^0 * 100 - // Large retry number (verify no overflow issues) - const largeRetry = exponentialDelay(10, 100) - // 2^10 * 100 = 102400ms base - assert.strictEqual(largeRetry >= 102400, true) - assert.strictEqual(largeRetry <= 122880, true) // 102400 + 20% + // Large retry number + const largeRetry = computeExponentialBackOffDelay({ baseDelayMs: 100, retryNumber: 10 }) + assert.strictEqual(largeRetry, 102400) // 2^10 * 100 - // Very small delay factor - const smallFactor = exponentialDelay(2, 1) - assert.strictEqual(smallFactor >= 4, true) // 2^2 * 1 - assert.strictEqual(smallFactor < 5, true) // 4 + 20% - }) + // Very small base + const smallBase = computeExponentialBackOffDelay({ baseDelayMs: 1, retryNumber: 2 }) + assert.strictEqual(smallBase, 4) // 2^2 * 1 + }) + + await it('should respect maxRetries cap', () => { + const baseDelayMs = 100 - await it('should calculate appropriate delays for WebSocket reconnection scenarios', () => { - // Simulate typical WebSocket reconnection delay sequence - const delayFactor = 100 // Default used in ChargingStation.reconnect() + // Without cap: 2^10 * 100 = 102400 + const uncapped = computeExponentialBackOffDelay({ baseDelayMs, retryNumber: 10 }) + assert.strictEqual(uncapped, 102400) - // First reconnect attempt (retry 1) - const firstDelay = exponentialDelay(1, delayFactor) - assert.strictEqual(firstDelay >= 200, true) // 2^1 * 100 - assert.strictEqual(firstDelay <= 240, true) + // With cap at 3: 2^3 * 100 = 800 (even though retryNumber is 10) + const capped = computeExponentialBackOffDelay({ baseDelayMs, maxRetries: 3, retryNumber: 10 }) + assert.strictEqual(capped, 800) + }) + + await it('should calculate appropriate delays for WebSocket reconnection scenarios', () => { + const baseDelayMs = 100 - // After several failures (retry 5) - const fifthDelay = exponentialDelay(5, delayFactor) - assert.strictEqual(fifthDelay >= 3200, true) // 2^5 * 100 - assert.strictEqual(fifthDelay <= 3840, true) + const firstDelay = computeExponentialBackOffDelay({ baseDelayMs, retryNumber: 1 }) + assert.strictEqual(firstDelay, 200) // 2^1 * 100 - // Maximum practical retry (retry 10 = ~102 seconds) - const maxDelay = exponentialDelay(10, delayFactor) - assert.strictEqual(maxDelay >= 102400, true) // ~102 seconds - assert.strictEqual(maxDelay <= 122880, true) + const fifthDelay = computeExponentialBackOffDelay({ baseDelayMs, retryNumber: 5 }) + assert.strictEqual(fifthDelay, 3200) // 2^5 * 100 + + const maxDelay = computeExponentialBackOffDelay({ baseDelayMs, retryNumber: 10 }) + assert.strictEqual(maxDelay, 102400) // ~102 seconds + }) }) await it('should return timestamped log prefix with optional string', () => { -- 2.43.0