type IncomingRequestCommand,
MessageType,
MeterValueMeasurand,
- type OCPPVersion,
+ OCPPVersion,
type OutgoingRequest,
PowerUnits,
RegistrationStatusEnumType,
buildUpdatedMessage,
clampToSafeTimerValue,
clone,
+ computeExponentialBackOffDelay,
Configuration,
Constants,
convertToBoolean,
convertToInt,
DCElectricUtils,
ensureError,
- exponentialDelay,
formatDurationMilliSeconds,
formatDurationSeconds,
getErrorMessage,
buildBootNotificationRequest,
createOCPPServices,
flushQueuedTransactionMessages,
+ OCPP20ServiceUtils,
OCPPAuthServiceFactory,
type OCPPIncomingRequestService,
type OCPPRequestService,
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 &&
// 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(
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
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 (
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
)
}
// 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
}
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(
}' has no authorize request pending`
)
}
+ OCPP16ServiceUtils.updateAuthorizationCache(
+ chargingStation,
+ requestPayload.idTag,
+ payload.idTagInfo
+ )
}
private handleResponseBootNotification (
)
await this.resetConnectorOnStartTransactionError(chargingStation, connectorId)
}
+ OCPP16ServiceUtils.updateAuthorizationCache(
+ chargingStation,
+ requestPayload.idTag,
+ payload.idTagInfo
+ )
}
private async handleResponseStopTransaction (
}
}
const transactionConnectorStatus = chargingStation.getConnectorStatus(transactionConnectorId)
+ const transactionIdTag = requestPayload.idTag ?? transactionConnectorStatus?.transactionIdTag
resetConnectorStatus(transactionConnectorStatus)
if (
transactionConnectorStatus != null &&
} else {
logger.warn(logMsg)
}
+ if (payload.idTagInfo != null && transactionIdTag != null) {
+ OCPP16ServiceUtils.updateAuthorizationCache(
+ chargingStation,
+ transactionIdTag,
+ payload.idTagInfo
+ )
+ }
}
private async resetConnectorOnStartTransactionError (
type OCPP16ChargingProfile,
type OCPP16ChargingSchedule,
type OCPP16ClearChargingProfileRequest,
+ type OCPP16IdTagInfo,
OCPP16IncomingRequestCommand,
type OCPP16MeterValue,
OCPP16MeterValueContext,
isNotEmptyArray,
logger,
roundTo,
+ truncateId,
} from '../../../utils/index.js'
+import {
+ AuthenticationMethod,
+ type AuthorizationResult,
+ mapOCPP16Status,
+ OCPPAuthServiceFactory,
+} from '../auth/index.js'
import {
buildEmptyMeterValue,
buildMeterValue,
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
}
/**
}
}
+ 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
--- /dev/null
+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)
+ }
+}
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'
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 {
)
}
+ 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
let state = this.stationsState.get(chargingStation)
if (state == null) {
state = {
+ isDrainingSecurityEvents: false,
preInoperativeConnectorStatuses: new Map(),
reportDataCache: new Map(),
+ securityEventQueue: [],
}
this.stationsState.set(chargingStation, state)
}
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: {
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,
}
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,
}
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)
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)
}
/**
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)`
)
)
// H11: Send SecurityEventNotification for successful firmware update
- await this.sendSecurityEventNotification(
+ this.sendSecurityEventNotification(
chargingStation,
'FirmwareUpdated',
`Firmware update completed for requestId ${requestId.toString()}`
import {
ChargingStationEvents,
ConnectorStatusEnum,
+ GenericStatus,
type JsonType,
OCPP20AuthorizationStatusEnumType,
+ type OCPP20AuthorizeRequest,
type OCPP20AuthorizeResponse,
type OCPP20BootNotificationResponse,
OCPP20ComponentName,
OCPP20OptionalVariableName,
OCPP20RequestCommand,
type OCPP20SecurityEventNotificationResponse,
+ type OCPP20SignCertificateRequest,
type OCPP20SignCertificateResponse,
type OCPP20StatusNotificationRequest,
type OCPP20StatusNotificationResponse,
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'
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 (
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 (
}
// 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) {
OCPP20ComponentName,
type OCPP20ConnectorStatusEnumType,
type OCPP20EVSEType,
+ type OCPP20IdTokenInfoType,
+ type OCPP20IdTokenType,
OCPP20IncomingRequestCommand,
type OCPP20MeterValue,
OCPP20OptionalVariableName,
} from '../../../types/index.js'
import {
clampToSafeTimerValue,
+ computeExponentialBackOffDelay,
Constants,
convertToBoolean,
convertToInt,
validateIdentifierString,
} from '../../../utils/index.js'
import { buildConfigKey, getConfigurationKey } from '../../ConfigurationKeyUtils.js'
+import { mapOCPP20TokenType, OCPPAuthServiceFactory } from '../auth/index.js'
import {
buildMeterValue,
createPayloadConfigs,
} 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
}
}
+ 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,
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,
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',
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',
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,
/**
* 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
*/
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
*/
import { type ChargingStation } from '../../../index.js'
import { AuthComponentFactory } from '../factories/AuthComponentFactory.js'
import {
+ type AuthCache,
type AuthStats,
type AuthStrategy,
type 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(
}
}
+ 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
// 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(
}
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`
logger.debug(`${moduleName}: Certificate authentication strategy cleaned up`)
}
+ public getAuthCache (): undefined {
+ return undefined
+ }
+
getStats (): JsonObject {
return {
...this.stats,
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
OCPP16AuthorizationStatus,
type OCPP16AuthorizeRequest,
type OCPP16AuthorizeResponse,
+ type OCPP16IdTagInfo,
type OCPP16StartTransactionRequest,
type OCPP16StartTransactionResponse,
OCPP16StopTransactionReason,
}
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 {
}
export interface OCPP16StartTransactionResponse extends JsonObject {
- idTagInfo: IdTagInfo
+ idTagInfo: OCPP16IdTagInfo
transactionId: number
}
}
export interface OCPP16StopTransactionResponse extends JsonObject {
- idTagInfo?: IdTagInfo
-}
-
-interface IdTagInfo extends JsonObject {
- expiryDate?: Date
- parentIdTag?: string
- status: OCPP16AuthorizationStatus
+ idTagInfo?: OCPP16IdTagInfo
}
}
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',
}
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
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
`${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
}
/**
export {
clampToSafeTimerValue,
clone,
+ computeExponentialBackOffDelay,
convertToBoolean,
convertToDate,
convertToFloat,
convertToInt,
convertToIntOrNaN,
- exponentialDelay,
extractTimeSeriesValues,
formatDurationMilliSeconds,
formatDurationSeconds,
/**
* @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,
OCPP16ChargingRateUnitType,
type OCPP16ChargingSchedule,
type OCPP16ClearChargingProfileRequest,
+ type OCPP16IdTagInfo,
OCPP16IncomingRequestCommand,
type OCPP16MeterValue,
OCPP16MeterValueContext,
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 () => {
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 ──────────────────────────────────────────
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)
+ })
+ })
})
--- /dev/null
+/**
+ * @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)
+ })
+ })
+ })
+})
/**
* @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'
--- /dev/null
+/**
+ * @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)
+ })
+ })
+})
--- /dev/null
+/**
+ * @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'
+ )
+ })
+})
import {
clampToSafeTimerValue,
clone,
+ computeExponentialBackOffDelay,
convertToBoolean,
convertToDate,
convertToFloat,
convertToInt,
convertToIntOrNaN,
- exponentialDelay,
extractTimeSeriesValues,
formatDurationMilliSeconds,
formatDurationSeconds,
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', () => {