]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ocpp): correct P0/P1 spec conformity gaps for OCPP 1.6 and 2.0.1
authorJérôme Benoit <jerome.benoit@sap.com>
Tue, 31 Mar 2026 18:51:32 +0000 (20:51 +0200)
committerJérôme Benoit <jerome.benoit@sap.com>
Tue, 31 Mar 2026 18:51:32 +0000 (20:51 +0200)
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

24 files changed:
src/charging-station/ChargingStation.ts
src/charging-station/ocpp/1.6/OCPP16ResponseService.ts
src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts
src/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.ts [new file with mode: 0644]
src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20ResponseService.ts
src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts
src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts
src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts
src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts
src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts
src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts
src/types/index.ts
src/types/ocpp/1.6/Transaction.ts
src/types/ocpp/2.0/Variables.ts
src/types/ocpp/ErrorType.ts
src/utils/Utils.ts
src/utils/index.ts
tests/charging-station/ocpp/1.6/OCPP16ServiceUtils.test.ts
tests/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20ResponseService-CacheUpdate.test.ts
tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-AuthCache.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-ReconnectDelay.test.ts [new file with mode: 0644]
tests/utils/Utils.test.ts

index 7552c4bde2cf10f7a31630ef3eeecfc69d8706a3..14c68d7f7f1f75c56c22c1298a9cc18a2c576213 100644 (file)
@@ -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
index a4a4b3d69c695006230610fbcea0f12ed87494a9..50f37e87630677ae3a77c69a598046472a3cb7c2 100644 (file)
@@ -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 (
index d0fc1e263a4b33e5201fe145602fdd9b8b962977..d6faa6f8db6a0664c83d972e4159d766e8c01385 100644 (file)
@@ -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 (file)
index 0000000..c883d85
--- /dev/null
@@ -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<typeof setTimeout> | 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<JsonType, OCPP20SignCertificateResponse>(
+          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)
+  }
+}
index aff6b6e0662bc3146271e24dfa07af574dfeea93..9b0df3f69be27757683904d8146abf33d9f4ade6 100644 (file)
@@ -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<number, OCPP20ConnectorStatusEnumType>
   reportDataCache: Map<number, ReportDataType[]>
+  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<OCPP20SecurityEventNotificationResponse> {
-    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()}`
index c0d970c51fb5bd7f44f492b066815853a741ebdc..4e3c92df5558095af2d1c73962e8468eb2849990 100644 (file)
@@ -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) {
index de52832cfa19d00c19145518cac296121636822f..19cdb4bd2d2084a78eed5447fa7aab1c2485616f 100644 (file)
@@ -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,
index b8109c5b7fa1e717204f21b9e190393670579c95..dfc71424110025008f315913d782634f0e28666e 100644 (file)
@@ -1406,53 +1406,62 @@ export const VARIABLE_REGISTRY: Record<string, VariableMetadata> = {
     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<string, VariableMetadata> = {
     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<string, VariableMetadata> = {
     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<string, VariableMetadata> = {
     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,
index d7782dfc46ca4855055e67a4aa5a6a87914384e6..e04e0c13005d1171d2963d2f5f670086f230a484 100644 (file)
@@ -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
    */
index 655e6c89c39e72e1fc980e8ff4e0e856499581ac..d257dfe2435352c3972aa2259024e941f0a9120b 100644 (file)
@@ -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`
index 7452a93fda5002207e98ae63e39823ffff049ec0..22798c15e58aa1797b33ffb0b62092ef03ca5dee 100644 (file)
@@ -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,
index acf185a62ea26eca66a8afbf717ee767989cdeea..898d78a4693157c01e1099529701e6c90c124342 100644 (file)
@@ -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
index aa893493e0431380932c04eab01ea94a4449741c..1d41b5a4fd3f33821e30318443850b8f0c5537ea 100644 (file)
@@ -144,6 +144,7 @@ export {
   OCPP16AuthorizationStatus,
   type OCPP16AuthorizeRequest,
   type OCPP16AuthorizeResponse,
+  type OCPP16IdTagInfo,
   type OCPP16StartTransactionRequest,
   type OCPP16StartTransactionResponse,
   OCPP16StopTransactionReason,
index 2aa5db38e8d5663a64ec27134c7cca0d83a137b2..6f87aeb86852991deaf16d12fa82d0984437d787 100644 (file)
@@ -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
 }
index 1a8608ab2c7b4e3cf4c603eb5cc929dc15bd0182..9c59e0f663a918f02f4be40e56db12b9d4629863 100644 (file)
@@ -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',
 }
index 40a6e7353ac25979ca10bff45a06d1d8cb2b2440..9fbb5e73198e81759f47838e679aa19d165198d6 100644 (file)
@@ -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
index 908d93dd2a3d24ee7a990597f61e254b34a66789..9a9e55a6ce126a0b58c422590a8bbcf95499a03f 100644 (file)
@@ -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
 }
 
 /**
index f1c900aa8153e104f252589b3c770ebce302f543..e6050e8847fa2c5ade2b7eec7858bc9433bf9581 100644 (file)
@@ -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,
index 720a7c9ec32509a0c81696379a1cb172d1050298..ab84ac37ade77048e034bc21a208056c974a1f1c 100644 (file)
@@ -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 (file)
index 0000000..b06ce77
--- /dev/null
@@ -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<typeof mock.fn<(...args: unknown[]) => Promise<unknown>>>
+
+  beforeEach(() => {
+    requestHandlerMock = mock.fn<(...args: unknown[]) => Promise<unknown>>(() =>
+      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<string, unknown>
+        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<string, unknown>
+        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)
+      })
+    })
+  })
+})
index 8df8cc9c80d64bd0bd6c0c3baa1a57707a7b16a5..b4aea42401cd6879109413db43fc96cc1eea48be 100644 (file)
@@ -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 (file)
index 0000000..9c441c2
--- /dev/null
@@ -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 (file)
index 0000000..fd34f17
--- /dev/null
@@ -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'
+    )
+  })
+})
index 0930d08dec21b7703e098c6e3c79f38501d5c767..84d3c4231526ffa00f4df5ac0f162a832a825c17 100644 (file)
@@ -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<number>()
+      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<number>()
-    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', () => {